增加注册奖励,金币流水,内购管理

This commit is contained in:
zhx 2026-05-12 23:31:44 +08:00
parent 1e542ba563
commit f7e182844d
139 changed files with 13041 additions and 4076 deletions

View File

@ -100,7 +100,7 @@ Authorization: Bearer <access_token>
- 客户端先经 gateway 调 `JoinRoom`,让用户进入 `room-service` presence。
- 客户端再用腾讯云 IM SDK 登录并加入 `room_id` 对应的群组。
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id``room-service` 由该 actor 收敛 owner 和默认 host,外部 CreateRoom 请求体不接收 `owner_user_id` 和 `host_user_id`。
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id``room-service` 由该 actor 收敛 owner外部 CreateRoom 请求体不接收 `owner_user_id`。
- `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
- `room-service``CreateRoom` 时通过腾讯云 IM REST API 创建同名群组。
- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后,通过腾讯云 IM REST API 发送房间系统自定义消息。

File diff suppressed because it is too large Load Diff

View File

@ -423,6 +423,114 @@ message SetTaskDefinitionStatusResponse {
TaskDefinition task = 1;
}
// RegistrationRewardConfig enabled=false
message RegistrationRewardConfig {
string app_code = 1;
bool enabled = 2;
string reward_type = 3;
int64 coin_amount = 4;
int64 resource_group_id = 5;
int64 daily_limit = 6;
int64 updated_by_admin_id = 7;
int64 created_at_ms = 8;
int64 updated_at_ms = 9;
}
// RegistrationRewardClaim user_id
message RegistrationRewardClaim {
string claim_id = 1;
string command_id = 2;
int64 user_id = 3;
string reward_day = 4;
string reward_type = 5;
int64 coin_amount = 6;
int64 resource_group_id = 7;
string status = 8;
string wallet_command_id = 9;
string wallet_transaction_id = 10;
string resource_grant_id = 11;
string failure_reason = 12;
int64 claimed_at_ms = 13;
int64 created_at_ms = 14;
int64 updated_at_ms = 15;
}
// RegistrationRewardEligibility App UTC reward_day
message RegistrationRewardEligibility {
bool can_receive = 1;
string reason = 2;
bool enabled = 3;
string reward_type = 4;
int64 coin_amount = 5;
int64 resource_group_id = 6;
int64 daily_limit = 7;
int64 daily_used = 8;
int64 daily_remaining = 9;
string reward_day = 10;
int64 server_time_ms = 11;
}
// GetRegistrationRewardEligibilityRequest App
message GetRegistrationRewardEligibilityRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message GetRegistrationRewardEligibilityResponse {
RegistrationRewardEligibility eligibility = 1;
}
// IssueRegistrationRewardRequest user-service
message IssueRegistrationRewardRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string command_id = 3;
}
message IssueRegistrationRewardResponse {
RegistrationRewardClaim claim = 1;
RegistrationRewardEligibility eligibility = 2;
bool issued = 3;
}
// GetRegistrationRewardConfigRequest disabled
message GetRegistrationRewardConfigRequest {
RequestMeta meta = 1;
}
message GetRegistrationRewardConfigResponse {
RegistrationRewardConfig config = 1;
}
// UpdateRegistrationRewardConfigRequest
message UpdateRegistrationRewardConfigRequest {
RequestMeta meta = 1;
bool enabled = 2;
string reward_type = 3;
int64 coin_amount = 4;
int64 resource_group_id = 5;
int64 daily_limit = 6;
int64 operator_admin_id = 7;
}
message UpdateRegistrationRewardConfigResponse {
RegistrationRewardConfig config = 1;
}
// ListRegistrationRewardClaimsRequest keyword admin-server
message ListRegistrationRewardClaimsRequest {
RequestMeta meta = 1;
string status = 2;
int64 user_id = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListRegistrationRewardClaimsResponse {
repeated RegistrationRewardClaim claims = 1;
int64 total = 2;
}
// ActivityService RPC
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
@ -472,3 +580,16 @@ service AdminTaskService {
rpc UpsertTaskDefinition(UpsertTaskDefinitionRequest) returns (UpsertTaskDefinitionResponse);
rpc SetTaskDefinitionStatus(SetTaskDefinitionStatusRequest) returns (SetTaskDefinitionStatusResponse);
}
// RegistrationRewardService App
service RegistrationRewardService {
rpc GetRegistrationRewardEligibility(GetRegistrationRewardEligibilityRequest) returns (GetRegistrationRewardEligibilityResponse);
rpc IssueRegistrationReward(IssueRegistrationRewardRequest) returns (IssueRegistrationRewardResponse);
}
// AdminRegistrationRewardService 访
service AdminRegistrationRewardService {
rpc GetRegistrationRewardConfig(GetRegistrationRewardConfigRequest) returns (GetRegistrationRewardConfigResponse);
rpc UpdateRegistrationRewardConfig(UpdateRegistrationRewardConfigRequest) returns (UpdateRegistrationRewardConfigResponse);
rpc ListRegistrationRewardClaims(ListRegistrationRewardClaimsRequest) returns (ListRegistrationRewardClaimsResponse);
}

View File

@ -1330,3 +1330,331 @@ var AdminTaskService_ServiceDesc = grpc.ServiceDesc{
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
RegistrationRewardService_GetRegistrationRewardEligibility_FullMethodName = "/hyapp.activity.v1.RegistrationRewardService/GetRegistrationRewardEligibility"
RegistrationRewardService_IssueRegistrationReward_FullMethodName = "/hyapp.activity.v1.RegistrationRewardService/IssueRegistrationReward"
)
// RegistrationRewardServiceClient is the client API for RegistrationRewardService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// RegistrationRewardService 拥有 App 查询和注册完成发放入口。
type RegistrationRewardServiceClient interface {
GetRegistrationRewardEligibility(ctx context.Context, in *GetRegistrationRewardEligibilityRequest, opts ...grpc.CallOption) (*GetRegistrationRewardEligibilityResponse, error)
IssueRegistrationReward(ctx context.Context, in *IssueRegistrationRewardRequest, opts ...grpc.CallOption) (*IssueRegistrationRewardResponse, error)
}
type registrationRewardServiceClient struct {
cc grpc.ClientConnInterface
}
func NewRegistrationRewardServiceClient(cc grpc.ClientConnInterface) RegistrationRewardServiceClient {
return &registrationRewardServiceClient{cc}
}
func (c *registrationRewardServiceClient) GetRegistrationRewardEligibility(ctx context.Context, in *GetRegistrationRewardEligibilityRequest, opts ...grpc.CallOption) (*GetRegistrationRewardEligibilityResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRegistrationRewardEligibilityResponse)
err := c.cc.Invoke(ctx, RegistrationRewardService_GetRegistrationRewardEligibility_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *registrationRewardServiceClient) IssueRegistrationReward(ctx context.Context, in *IssueRegistrationRewardRequest, opts ...grpc.CallOption) (*IssueRegistrationRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IssueRegistrationRewardResponse)
err := c.cc.Invoke(ctx, RegistrationRewardService_IssueRegistrationReward_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RegistrationRewardServiceServer is the server API for RegistrationRewardService service.
// All implementations must embed UnimplementedRegistrationRewardServiceServer
// for forward compatibility.
//
// RegistrationRewardService 拥有 App 查询和注册完成发放入口。
type RegistrationRewardServiceServer interface {
GetRegistrationRewardEligibility(context.Context, *GetRegistrationRewardEligibilityRequest) (*GetRegistrationRewardEligibilityResponse, error)
IssueRegistrationReward(context.Context, *IssueRegistrationRewardRequest) (*IssueRegistrationRewardResponse, error)
mustEmbedUnimplementedRegistrationRewardServiceServer()
}
// UnimplementedRegistrationRewardServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedRegistrationRewardServiceServer struct{}
func (UnimplementedRegistrationRewardServiceServer) GetRegistrationRewardEligibility(context.Context, *GetRegistrationRewardEligibilityRequest) (*GetRegistrationRewardEligibilityResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRegistrationRewardEligibility not implemented")
}
func (UnimplementedRegistrationRewardServiceServer) IssueRegistrationReward(context.Context, *IssueRegistrationRewardRequest) (*IssueRegistrationRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IssueRegistrationReward not implemented")
}
func (UnimplementedRegistrationRewardServiceServer) mustEmbedUnimplementedRegistrationRewardServiceServer() {
}
func (UnimplementedRegistrationRewardServiceServer) testEmbeddedByValue() {}
// UnsafeRegistrationRewardServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to RegistrationRewardServiceServer will
// result in compilation errors.
type UnsafeRegistrationRewardServiceServer interface {
mustEmbedUnimplementedRegistrationRewardServiceServer()
}
func RegisterRegistrationRewardServiceServer(s grpc.ServiceRegistrar, srv RegistrationRewardServiceServer) {
// If the following call pancis, it indicates UnimplementedRegistrationRewardServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&RegistrationRewardService_ServiceDesc, srv)
}
func _RegistrationRewardService_GetRegistrationRewardEligibility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRegistrationRewardEligibilityRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistrationRewardServiceServer).GetRegistrationRewardEligibility(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RegistrationRewardService_GetRegistrationRewardEligibility_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RegistrationRewardServiceServer).GetRegistrationRewardEligibility(ctx, req.(*GetRegistrationRewardEligibilityRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RegistrationRewardService_IssueRegistrationReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IssueRegistrationRewardRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RegistrationRewardServiceServer).IssueRegistrationReward(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RegistrationRewardService_IssueRegistrationReward_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RegistrationRewardServiceServer).IssueRegistrationReward(ctx, req.(*IssueRegistrationRewardRequest))
}
return interceptor(ctx, in, info, handler)
}
// RegistrationRewardService_ServiceDesc is the grpc.ServiceDesc for RegistrationRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var RegistrationRewardService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.RegistrationRewardService",
HandlerType: (*RegistrationRewardServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetRegistrationRewardEligibility",
Handler: _RegistrationRewardService_GetRegistrationRewardEligibility_Handler,
},
{
MethodName: "IssueRegistrationReward",
Handler: _RegistrationRewardService_IssueRegistrationReward_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminRegistrationRewardService_GetRegistrationRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminRegistrationRewardService/GetRegistrationRewardConfig"
AdminRegistrationRewardService_UpdateRegistrationRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminRegistrationRewardService/UpdateRegistrationRewardConfig"
AdminRegistrationRewardService_ListRegistrationRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminRegistrationRewardService/ListRegistrationRewardClaims"
)
// AdminRegistrationRewardServiceClient is the client API for AdminRegistrationRewardService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminRegistrationRewardService 是后台活动管理访问注册奖励配置和领取记录的唯一入口。
type AdminRegistrationRewardServiceClient interface {
GetRegistrationRewardConfig(ctx context.Context, in *GetRegistrationRewardConfigRequest, opts ...grpc.CallOption) (*GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, in *UpdateRegistrationRewardConfigRequest, opts ...grpc.CallOption) (*UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, in *ListRegistrationRewardClaimsRequest, opts ...grpc.CallOption) (*ListRegistrationRewardClaimsResponse, error)
}
type adminRegistrationRewardServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminRegistrationRewardServiceClient(cc grpc.ClientConnInterface) AdminRegistrationRewardServiceClient {
return &adminRegistrationRewardServiceClient{cc}
}
func (c *adminRegistrationRewardServiceClient) GetRegistrationRewardConfig(ctx context.Context, in *GetRegistrationRewardConfigRequest, opts ...grpc.CallOption) (*GetRegistrationRewardConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRegistrationRewardConfigResponse)
err := c.cc.Invoke(ctx, AdminRegistrationRewardService_GetRegistrationRewardConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminRegistrationRewardServiceClient) UpdateRegistrationRewardConfig(ctx context.Context, in *UpdateRegistrationRewardConfigRequest, opts ...grpc.CallOption) (*UpdateRegistrationRewardConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateRegistrationRewardConfigResponse)
err := c.cc.Invoke(ctx, AdminRegistrationRewardService_UpdateRegistrationRewardConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminRegistrationRewardServiceClient) ListRegistrationRewardClaims(ctx context.Context, in *ListRegistrationRewardClaimsRequest, opts ...grpc.CallOption) (*ListRegistrationRewardClaimsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRegistrationRewardClaimsResponse)
err := c.cc.Invoke(ctx, AdminRegistrationRewardService_ListRegistrationRewardClaims_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminRegistrationRewardServiceServer is the server API for AdminRegistrationRewardService service.
// All implementations must embed UnimplementedAdminRegistrationRewardServiceServer
// for forward compatibility.
//
// AdminRegistrationRewardService 是后台活动管理访问注册奖励配置和领取记录的唯一入口。
type AdminRegistrationRewardServiceServer interface {
GetRegistrationRewardConfig(context.Context, *GetRegistrationRewardConfigRequest) (*GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(context.Context, *UpdateRegistrationRewardConfigRequest) (*UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(context.Context, *ListRegistrationRewardClaimsRequest) (*ListRegistrationRewardClaimsResponse, error)
mustEmbedUnimplementedAdminRegistrationRewardServiceServer()
}
// UnimplementedAdminRegistrationRewardServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminRegistrationRewardServiceServer struct{}
func (UnimplementedAdminRegistrationRewardServiceServer) GetRegistrationRewardConfig(context.Context, *GetRegistrationRewardConfigRequest) (*GetRegistrationRewardConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRegistrationRewardConfig not implemented")
}
func (UnimplementedAdminRegistrationRewardServiceServer) UpdateRegistrationRewardConfig(context.Context, *UpdateRegistrationRewardConfigRequest) (*UpdateRegistrationRewardConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateRegistrationRewardConfig not implemented")
}
func (UnimplementedAdminRegistrationRewardServiceServer) ListRegistrationRewardClaims(context.Context, *ListRegistrationRewardClaimsRequest) (*ListRegistrationRewardClaimsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRegistrationRewardClaims not implemented")
}
func (UnimplementedAdminRegistrationRewardServiceServer) mustEmbedUnimplementedAdminRegistrationRewardServiceServer() {
}
func (UnimplementedAdminRegistrationRewardServiceServer) testEmbeddedByValue() {}
// UnsafeAdminRegistrationRewardServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminRegistrationRewardServiceServer will
// result in compilation errors.
type UnsafeAdminRegistrationRewardServiceServer interface {
mustEmbedUnimplementedAdminRegistrationRewardServiceServer()
}
func RegisterAdminRegistrationRewardServiceServer(s grpc.ServiceRegistrar, srv AdminRegistrationRewardServiceServer) {
// If the following call pancis, it indicates UnimplementedAdminRegistrationRewardServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminRegistrationRewardService_ServiceDesc, srv)
}
func _AdminRegistrationRewardService_GetRegistrationRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRegistrationRewardConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminRegistrationRewardServiceServer).GetRegistrationRewardConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminRegistrationRewardService_GetRegistrationRewardConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminRegistrationRewardServiceServer).GetRegistrationRewardConfig(ctx, req.(*GetRegistrationRewardConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminRegistrationRewardService_UpdateRegistrationRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateRegistrationRewardConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminRegistrationRewardServiceServer).UpdateRegistrationRewardConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminRegistrationRewardService_UpdateRegistrationRewardConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminRegistrationRewardServiceServer).UpdateRegistrationRewardConfig(ctx, req.(*UpdateRegistrationRewardConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminRegistrationRewardService_ListRegistrationRewardClaims_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRegistrationRewardClaimsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminRegistrationRewardServiceServer).ListRegistrationRewardClaims(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminRegistrationRewardService_ListRegistrationRewardClaims_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminRegistrationRewardServiceServer).ListRegistrationRewardClaims(ctx, req.(*ListRegistrationRewardClaimsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminRegistrationRewardService_ServiceDesc is the grpc.ServiceDesc for AdminRegistrationRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminRegistrationRewardService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminRegistrationRewardService",
HandlerType: (*AdminRegistrationRewardServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetRegistrationRewardConfig",
Handler: _AdminRegistrationRewardService_GetRegistrationRewardConfig_Handler,
},
{
MethodName: "UpdateRegistrationRewardConfig",
Handler: _AdminRegistrationRewardService_UpdateRegistrationRewardConfig_Handler,
},
{
MethodName: "ListRegistrationRewardClaims",
Handler: _AdminRegistrationRewardService_ListRegistrationRewardClaims_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}

View File

@ -122,7 +122,6 @@ type RoomCreated struct {
unknownFields protoimpl.UnknownFields
OwnerUserId int64 `protobuf:"varint,1,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
HostUserId int64 `protobuf:"varint,2,opt,name=host_user_id,json=hostUserId,proto3" json:"host_user_id,omitempty"`
SeatCount int32 `protobuf:"varint,3,opt,name=seat_count,json=seatCount,proto3" json:"seat_count,omitempty"`
Mode string `protobuf:"bytes,4,opt,name=mode,proto3" json:"mode,omitempty"`
// room_name/room_avatar/room_description 是创建时确定的展示资料快照,消费者不需要反查 room-service。
@ -168,13 +167,6 @@ func (x *RoomCreated) GetOwnerUserId() int64 {
return 0
}
func (x *RoomCreated) GetHostUserId() int64 {
if x != nil {
return x.HostUserId
}
return 0
}
func (x *RoomCreated) GetSeatCount() int32 {
if x != nil {
return x.SeatCount
@ -749,68 +741,6 @@ func (x *RoomAdminChanged) GetEnabled() bool {
return false
}
// RoomHostTransferred 表达主持人身份转移。
type RoomHostTransferred struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ActorUserId int64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
OldHostUserId int64 `protobuf:"varint,2,opt,name=old_host_user_id,json=oldHostUserId,proto3" json:"old_host_user_id,omitempty"`
NewHostUserId int64 `protobuf:"varint,3,opt,name=new_host_user_id,json=newHostUserId,proto3" json:"new_host_user_id,omitempty"`
}
func (x *RoomHostTransferred) Reset() {
*x = RoomHostTransferred{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomHostTransferred) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RoomHostTransferred) ProtoMessage() {}
func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RoomHostTransferred.ProtoReflect.Descriptor instead.
func (*RoomHostTransferred) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{10}
}
func (x *RoomHostTransferred) GetActorUserId() int64 {
if x != nil {
return x.ActorUserId
}
return 0
}
func (x *RoomHostTransferred) GetOldHostUserId() int64 {
if x != nil {
return x.OldHostUserId
}
return 0
}
func (x *RoomHostTransferred) GetNewHostUserId() int64 {
if x != nil {
return x.NewHostUserId
}
return 0
}
// RoomUserMuted 表达禁言状态变更。
type RoomUserMuted struct {
state protoimpl.MessageState
@ -824,7 +754,7 @@ type RoomUserMuted struct {
func (x *RoomUserMuted) Reset() {
*x = RoomUserMuted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -836,7 +766,7 @@ func (x *RoomUserMuted) String() string {
func (*RoomUserMuted) ProtoMessage() {}
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -849,7 +779,7 @@ func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomUserMuted.ProtoReflect.Descriptor instead.
func (*RoomUserMuted) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{10}
}
func (x *RoomUserMuted) GetActorUserId() int64 {
@ -885,7 +815,7 @@ type RoomUserKicked struct {
func (x *RoomUserKicked) Reset() {
*x = RoomUserKicked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -897,7 +827,7 @@ func (x *RoomUserKicked) String() string {
func (*RoomUserKicked) ProtoMessage() {}
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -910,7 +840,7 @@ func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomUserKicked.ProtoReflect.Descriptor instead.
func (*RoomUserKicked) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
}
func (x *RoomUserKicked) GetActorUserId() int64 {
@ -939,7 +869,7 @@ type RoomUserUnbanned struct {
func (x *RoomUserUnbanned) Reset() {
*x = RoomUserUnbanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -951,7 +881,7 @@ func (x *RoomUserUnbanned) String() string {
func (*RoomUserUnbanned) ProtoMessage() {}
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -964,7 +894,7 @@ func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomUserUnbanned.ProtoReflect.Descriptor instead.
func (*RoomUserUnbanned) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{13}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
}
func (x *RoomUserUnbanned) GetActorUserId() int64 {
@ -999,7 +929,7 @@ type RoomGiftSent struct {
func (x *RoomGiftSent) Reset() {
*x = RoomGiftSent{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1011,7 +941,7 @@ func (x *RoomGiftSent) String() string {
func (*RoomGiftSent) ProtoMessage() {}
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1024,7 +954,7 @@ func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomGiftSent.ProtoReflect.Descriptor instead.
func (*RoomGiftSent) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{13}
}
func (x *RoomGiftSent) GetSenderUserId() int64 {
@ -1095,7 +1025,7 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1107,7 +1037,7 @@ func (x *RoomHeatChanged) String() string {
func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1120,7 +1050,7 @@ func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomHeatChanged.ProtoReflect.Descriptor instead.
func (*RoomHeatChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{15}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
}
func (x *RoomHeatChanged) GetDelta() int64 {
@ -1150,7 +1080,7 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1162,7 +1092,7 @@ func (x *RoomRankChanged) String() string {
func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1175,7 +1105,7 @@ func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRankChanged.ProtoReflect.Descriptor instead.
func (*RoomRankChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{16}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{15}
}
func (x *RoomRankChanged) GetUserId() int64 {
@ -1219,99 +1149,88 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x72, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18,
0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x61,
0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61,
0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0xef, 0x01, 0x0a, 0x0b, 0x52, 0x6f, 0x6f, 0x6d, 0x43,
0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0xcd, 0x01, 0x0a, 0x0b, 0x52, 0x6f, 0x6f, 0x6d, 0x43,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6f,
0x77, 0x6e, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0c, 0x68, 0x6f,
0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x73, 0x65, 0x61, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05,
0x52, 0x09, 0x73, 0x65, 0x61, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6d,
0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12,
0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6f, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b,
0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0a, 0x72, 0x6f, 0x6f, 0x6d, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x29, 0x0a,
0x10, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x72, 0x6f, 0x6f, 0x6d, 0x44, 0x65, 0x73,
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xc0, 0x01, 0x0a, 0x12, 0x52, 0x6f, 0x6f,
0x6d, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6f, 0x6d, 0x4e, 0x61, 0x6d, 0x65,
0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x6f, 0x6f, 0x6d, 0x41, 0x76, 0x61, 0x74, 0x61,
0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x72, 0x6f, 0x6f,
0x6d, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a,
0x73, 0x65, 0x61, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05,
0x52, 0x09, 0x73, 0x65, 0x61, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3d, 0x0a, 0x0e, 0x52,
0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4a, 0x6f, 0x69, 0x6e, 0x65, 0x64, 0x12, 0x17, 0x0a,
0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06,
0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x27, 0x0a, 0x0c, 0x52, 0x6f,
0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4c, 0x65, 0x66, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
0x72, 0x49, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x6c, 0x6f, 0x73, 0x65,
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x8b, 0x03,
0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64,
0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61,
0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72,
0x6f, 0x6d, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x66,
0x72, 0x6f, 0x6d, 0x53, 0x65, 0x61, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x6f, 0x5f, 0x73, 0x65,
0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x74, 0x6f, 0x53, 0x65, 0x61, 0x74,
0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x69, 0x63, 0x5f,
0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0c, 0x6d, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x23,
0x0a, 0x0d, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x53, 0x74,
0x61, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x08, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x64, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f,
0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73,
0x68, 0x44, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x73, 0x12, 0x31, 0x0a, 0x15, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d,
0x65, 0x5f, 0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x70, 0x75, 0x62, 0x6c,
0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x1b,
0x0a, 0x09, 0x6d, 0x69, 0x63, 0x5f, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28,
0x08, 0x52, 0x08, 0x6d, 0x69, 0x63, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x68, 0x0a, 0x11, 0x52,
0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65, 0x61, 0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a,
0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c,
0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61,
0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02,
0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x76, 0x0a,
0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x8b, 0x01, 0x0a, 0x13, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x6f,
0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x12, 0x22, 0x0a,
0x77, 0x6e, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65,
0x61, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09,
0x73, 0x65, 0x61, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64,
0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a,
0x09, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x72, 0x6f, 0x6f, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x6f,
0x6f, 0x6d, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0a, 0x72, 0x6f, 0x6f, 0x6d, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x72,
0x6f, 0x6f, 0x6d, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18,
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x72, 0x6f, 0x6f, 0x6d, 0x44, 0x65, 0x73, 0x63, 0x72,
0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xc0, 0x01, 0x0a, 0x12, 0x52, 0x6f, 0x6f, 0x6d, 0x50,
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a,
0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x27, 0x0a, 0x10, 0x6f, 0x6c, 0x64, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6f, 0x6c, 0x64,
0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x6e, 0x65,
0x77, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d,
0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x6f, 0x6d, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f,
0x0a, 0x0b, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x6f, 0x6f, 0x6d, 0x41, 0x76, 0x61, 0x74, 0x61, 0x72, 0x12,
0x29, 0x0a, 0x10, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74,
0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x72, 0x6f, 0x6f, 0x6d, 0x44,
0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65,
0x61, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09,
0x73, 0x65, 0x61, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3d, 0x0a, 0x0e, 0x52, 0x6f, 0x6f,
0x6d, 0x55, 0x73, 0x65, 0x72, 0x4a, 0x6f, 0x69, 0x6e, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x27, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d,
0x55, 0x73, 0x65, 0x72, 0x4c, 0x65, 0x66, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49,
0x64, 0x22, 0x48, 0x0a, 0x0a, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x8b, 0x03, 0x0a, 0x0e,
0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22,
0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x6f, 0x6d,
0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x66, 0x72, 0x6f,
0x6d, 0x53, 0x65, 0x61, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x6f, 0x5f, 0x73, 0x65, 0x61, 0x74,
0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x74, 0x6f, 0x53, 0x65, 0x61, 0x74, 0x12, 0x16,
0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x69, 0x63, 0x5f, 0x73, 0x65,
0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
0x6d, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d,
0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x53, 0x74, 0x61, 0x74,
0x65, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x70, 0x75, 0x62,
0x6c, 0x69, 0x73, 0x68, 0x5f, 0x64, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x6d, 0x73,
0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x44,
0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x73, 0x12, 0x31, 0x0a, 0x15, 0x70, 0x75, 0x62,
0x6c, 0x69, 0x73, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f,
0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73,
0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x1b, 0x0a, 0x09,
0x6d, 0x69, 0x63, 0x5f, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52,
0x08, 0x6d, 0x69, 0x63, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x68, 0x0a, 0x11, 0x52, 0x6f, 0x6f,
0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22,
0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18, 0x02, 0x20,
0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65, 0x61, 0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x6c,
0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x63,
0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61, 0x74, 0x45,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a,
0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x76, 0x0a, 0x10, 0x52,
0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72,
0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61,
0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62,
0x6c, 0x65, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d,
0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
@ -1378,7 +1297,7 @@ func file_proto_events_room_v1_events_proto_rawDescGZIP() []byte {
return file_proto_events_room_v1_events_proto_rawDescData
}
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_proto_events_room_v1_events_proto_goTypes = []any{
(*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope
(*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated
@ -1390,13 +1309,12 @@ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked
(*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged
(*RoomAdminChanged)(nil), // 9: hyapp.events.room.v1.RoomAdminChanged
(*RoomHostTransferred)(nil), // 10: hyapp.events.room.v1.RoomHostTransferred
(*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent
(*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged
(*RoomUserMuted)(nil), // 10: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 11: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 12: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 13: hyapp.events.room.v1.RoomGiftSent
(*RoomHeatChanged)(nil), // 14: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 15: hyapp.events.room.v1.RoomRankChanged
}
var file_proto_events_room_v1_events_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
@ -1417,7 +1335,7 @@ func file_proto_events_room_v1_events_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_events_room_v1_events_proto_rawDesc,
NumEnums: 0,
NumMessages: 17,
NumMessages: 16,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -19,7 +19,6 @@ message EventEnvelope {
// RoomCreated
message RoomCreated {
int64 owner_user_id = 1;
int64 host_user_id = 2;
int32 seat_count = 3;
string mode = 4;
// room_name/room_avatar/room_description room-service
@ -92,13 +91,6 @@ message RoomAdminChanged {
bool enabled = 3;
}
// RoomHostTransferred
message RoomHostTransferred {
int64 actor_user_id = 1;
int64 old_host_user_id = 2;
int64 new_host_user_id = 3;
}
// RoomUserMuted
message RoomUserMuted {
int64 actor_user_id = 1;

File diff suppressed because it is too large Load Diff

View File

@ -65,7 +65,6 @@ message RankItem {
message RoomSnapshot {
string room_id = 1;
int64 owner_user_id = 2;
int64 host_user_id = 3;
string mode = 4;
string status = 5;
bool chat_enabled = 6;
@ -309,18 +308,6 @@ message SetRoomAdminResponse {
RoomSnapshot room = 2;
}
// TransferRoomHostRequest
message TransferRoomHostRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
}
// TransferRoomHostResponse
message TransferRoomHostResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
}
// MuteUserRequest
message MuteUserRequest {
RequestMeta meta = 1;
@ -358,6 +345,27 @@ message UnbanUserResponse {
RoomSnapshot room = 2;
}
// SystemEvictUserRequest
// Room Cell presence
message SystemEvictUserRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
int64 operator_user_id = 3;
string reason = 4;
// ban_from_room true ban
bool ban_from_room = 5;
}
// SystemEvictUserResponse RTC
message SystemEvictUserResponse {
bool had_current_room = 1;
CommandResult result = 2;
RoomSnapshot room = 3;
string room_id = 4;
bool rtc_kicked = 5;
string rtc_kick_error = 6;
}
// SendGiftRequest
message SendGiftRequest {
RequestMeta meta = 1;
@ -445,7 +453,6 @@ message RoomFeedRelatedUser {
message RoomListItem {
string room_id = 1;
int64 owner_user_id = 2;
int64 host_user_id = 3;
string title = 4;
string cover_url = 5;
string mode = 6;
@ -549,10 +556,10 @@ service RoomCommandService {
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
rpc TransferRoomHost(TransferRoomHostRequest) returns (TransferRoomHostResponse);
rpc MuteUser(MuteUserRequest) returns (MuteUserResponse);
rpc KickUser(KickUserRequest) returns (KickUserResponse);
rpc UnbanUser(UnbanUserRequest) returns (UnbanUserResponse);
rpc SystemEvictUser(SystemEvictUserRequest) returns (SystemEvictUserResponse);
rpc SendGift(SendGiftRequest) returns (SendGiftResponse);
}

View File

@ -34,10 +34,10 @@ const (
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
RoomCommandService_TransferRoomHost_FullMethodName = "/hyapp.room.v1.RoomCommandService/TransferRoomHost"
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
RoomCommandService_SystemEvictUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/SystemEvictUser"
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
)
@ -62,10 +62,10 @@ type RoomCommandServiceClient interface {
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, error)
TransferRoomHost(ctx context.Context, in *TransferRoomHostRequest, opts ...grpc.CallOption) (*TransferRoomHostResponse, error)
MuteUser(ctx context.Context, in *MuteUserRequest, opts ...grpc.CallOption) (*MuteUserResponse, error)
KickUser(ctx context.Context, in *KickUserRequest, opts ...grpc.CallOption) (*KickUserResponse, error)
UnbanUser(ctx context.Context, in *UnbanUserRequest, opts ...grpc.CallOption) (*UnbanUserResponse, error)
SystemEvictUser(ctx context.Context, in *SystemEvictUserRequest, opts ...grpc.CallOption) (*SystemEvictUserResponse, error)
SendGift(ctx context.Context, in *SendGiftRequest, opts ...grpc.CallOption) (*SendGiftResponse, error)
}
@ -227,16 +227,6 @@ func (c *roomCommandServiceClient) SetRoomAdmin(ctx context.Context, in *SetRoom
return out, nil
}
func (c *roomCommandServiceClient) TransferRoomHost(ctx context.Context, in *TransferRoomHostRequest, opts ...grpc.CallOption) (*TransferRoomHostResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TransferRoomHostResponse)
err := c.cc.Invoke(ctx, RoomCommandService_TransferRoomHost_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) MuteUser(ctx context.Context, in *MuteUserRequest, opts ...grpc.CallOption) (*MuteUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MuteUserResponse)
@ -267,6 +257,16 @@ func (c *roomCommandServiceClient) UnbanUser(ctx context.Context, in *UnbanUserR
return out, nil
}
func (c *roomCommandServiceClient) SystemEvictUser(ctx context.Context, in *SystemEvictUserRequest, opts ...grpc.CallOption) (*SystemEvictUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SystemEvictUserResponse)
err := c.cc.Invoke(ctx, RoomCommandService_SystemEvictUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) SendGift(ctx context.Context, in *SendGiftRequest, opts ...grpc.CallOption) (*SendGiftResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SendGiftResponse)
@ -298,10 +298,10 @@ type RoomCommandServiceServer interface {
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error)
TransferRoomHost(context.Context, *TransferRoomHostRequest) (*TransferRoomHostResponse, error)
MuteUser(context.Context, *MuteUserRequest) (*MuteUserResponse, error)
KickUser(context.Context, *KickUserRequest) (*KickUserResponse, error)
UnbanUser(context.Context, *UnbanUserRequest) (*UnbanUserResponse, error)
SystemEvictUser(context.Context, *SystemEvictUserRequest) (*SystemEvictUserResponse, error)
SendGift(context.Context, *SendGiftRequest) (*SendGiftResponse, error)
mustEmbedUnimplementedRoomCommandServiceServer()
}
@ -358,9 +358,6 @@ func (UnimplementedRoomCommandServiceServer) SetChatEnabled(context.Context, *Se
func (UnimplementedRoomCommandServiceServer) SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetRoomAdmin not implemented")
}
func (UnimplementedRoomCommandServiceServer) TransferRoomHost(context.Context, *TransferRoomHostRequest) (*TransferRoomHostResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TransferRoomHost not implemented")
}
func (UnimplementedRoomCommandServiceServer) MuteUser(context.Context, *MuteUserRequest) (*MuteUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MuteUser not implemented")
}
@ -370,6 +367,9 @@ func (UnimplementedRoomCommandServiceServer) KickUser(context.Context, *KickUser
func (UnimplementedRoomCommandServiceServer) UnbanUser(context.Context, *UnbanUserRequest) (*UnbanUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnbanUser not implemented")
}
func (UnimplementedRoomCommandServiceServer) SystemEvictUser(context.Context, *SystemEvictUserRequest) (*SystemEvictUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SystemEvictUser not implemented")
}
func (UnimplementedRoomCommandServiceServer) SendGift(context.Context, *SendGiftRequest) (*SendGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendGift not implemented")
}
@ -664,24 +664,6 @@ func _RoomCommandService_SetRoomAdmin_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_TransferRoomHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TransferRoomHostRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).TransferRoomHost(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_TransferRoomHost_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).TransferRoomHost(ctx, req.(*TransferRoomHostRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_MuteUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MuteUserRequest)
if err := dec(in); err != nil {
@ -736,6 +718,24 @@ func _RoomCommandService_UnbanUser_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SystemEvictUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SystemEvictUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).SystemEvictUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_SystemEvictUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).SystemEvictUser(ctx, req.(*SystemEvictUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SendGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendGiftRequest)
if err := dec(in); err != nil {
@ -821,10 +821,6 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetRoomAdmin",
Handler: _RoomCommandService_SetRoomAdmin_Handler,
},
{
MethodName: "TransferRoomHost",
Handler: _RoomCommandService_TransferRoomHost_Handler,
},
{
MethodName: "MuteUser",
Handler: _RoomCommandService_MuteUser_Handler,
@ -837,6 +833,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UnbanUser",
Handler: _RoomCommandService_UnbanUser_Handler,
},
{
MethodName: "SystemEvictUser",
Handler: _RoomCommandService_SystemEvictUser_Handler,
},
{
MethodName: "SendGift",
Handler: _RoomCommandService_SendGift_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -419,6 +419,34 @@ message ChangeUserCountryResponse {
bool region_changed = 5;
}
// SetUserStatusRequest
// active refresh session access session gateway denylist
message SetUserStatusRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
UserStatus status = 3;
int64 operator_user_id = 4;
string reason = 5;
// operator_type adminapp_usersystem ID
string operator_type = 6;
}
// SetUserStatusResponse
// App
message SetUserStatusResponse {
User user = 1;
int64 revoked_session_count = 2;
bool im_kicked = 3;
string im_kick_error = 4;
bool room_evicted = 5;
string room_id = 6;
bool rtc_kicked = 7;
string room_evict_error = 8;
string rtc_kick_error = 9;
bool access_token_revoked = 10;
string access_token_revoke_error = 11;
}
// CompleteOnboardingRequest
message CompleteOnboardingRequest {
RequestMeta meta = 1;
@ -657,6 +685,7 @@ service UserService {
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
}

View File

@ -27,6 +27,7 @@ const (
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
)
@ -44,6 +45,7 @@ type UserServiceClient interface {
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
}
@ -135,6 +137,16 @@ func (c *userServiceClient) ChangeUserCountry(ctx context.Context, in *ChangeUse
return out, nil
}
func (c *userServiceClient) SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetUserStatusResponse)
err := c.cc.Invoke(ctx, UserService_SetUserStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CompleteOnboardingResponse)
@ -159,6 +171,7 @@ type UserServiceServer interface {
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
mustEmbedUnimplementedUserServiceServer()
}
@ -194,6 +207,9 @@ func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *Update
func (UnimplementedUserServiceServer) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ChangeUserCountry not implemented")
}
func (UnimplementedUserServiceServer) SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetUserStatus not implemented")
}
func (UnimplementedUserServiceServer) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CompleteOnboarding not implemented")
}
@ -362,6 +378,24 @@ func _UserService_ChangeUserCountry_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler)
}
func _UserService_SetUserStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetUserStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).SetUserStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_SetUserStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).SetUserStatus(ctx, req.(*SetUserStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_CompleteOnboarding_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CompleteOnboardingRequest)
if err := dec(in); err != nil {
@ -419,6 +453,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ChangeUserCountry",
Handler: _UserService_ChangeUserCountry_Handler,
},
{
MethodName: "SetUserStatus",
Handler: _UserService_SetUserStatus_Handler,
},
{
MethodName: "CompleteOnboarding",
Handler: _UserService_CompleteOnboarding_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -629,6 +629,18 @@ message RechargeProduct {
string policy_version = 7;
bool enabled = 8;
int32 sort_order = 9;
string product_name = 10;
string description = 11;
string platform = 12;
repeated int64 region_ids = 13;
string resource_asset_type = 14;
int64 amount_micro = 15;
int64 created_by_user_id = 16;
int64 updated_by_user_id = 17;
int64 created_at_ms = 18;
int64 updated_at_ms = 19;
string app_code = 20;
string status = 21;
}
message ListRechargeProductsRequest {
@ -636,6 +648,8 @@ message ListRechargeProductsRequest {
string app_code = 2;
int64 user_id = 3;
int64 region_id = 4;
// platform gateway App
string platform = 5;
}
message ListRechargeProductsResponse {
@ -643,6 +657,66 @@ message ListRechargeProductsResponse {
repeated string channels = 2;
}
// ListAdminRechargeProductsRequest
message ListAdminRechargeProductsRequest {
string request_id = 1;
string app_code = 2;
string status = 3;
string platform = 4;
int64 region_id = 5;
string keyword = 6;
int32 page = 7;
int32 page_size = 8;
}
message ListAdminRechargeProductsResponse {
repeated RechargeProduct products = 1;
int64 total = 2;
}
// CreateRechargeProductRequest App COIN
message CreateRechargeProductRequest {
string request_id = 1;
string app_code = 2;
int64 amount_micro = 3;
int64 coin_amount = 4;
string product_name = 5;
string description = 6;
string platform = 7;
repeated int64 region_ids = 8;
bool enabled = 9;
int64 operator_user_id = 10;
}
message UpdateRechargeProductRequest {
string request_id = 1;
string app_code = 2;
int64 product_id = 3;
int64 amount_micro = 4;
int64 coin_amount = 5;
string product_name = 6;
string description = 7;
string platform = 8;
repeated int64 region_ids = 9;
bool enabled = 10;
int64 operator_user_id = 11;
}
message DeleteRechargeProductRequest {
string request_id = 1;
string app_code = 2;
int64 product_id = 3;
int64 operator_user_id = 4;
}
message RechargeProductResponse {
RechargeProduct product = 1;
}
message DeleteRechargeProductResponse {
bool deleted = 1;
}
message DiamondExchangeRule {
string exchange_type = 1;
string from_asset_type = 2;
@ -858,6 +932,10 @@ service WalletService {
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
rpc ListAdminRechargeProducts(ListAdminRechargeProductsRequest) returns (ListAdminRechargeProductsResponse);
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);
rpc DeleteRechargeProduct(DeleteRechargeProductRequest) returns (DeleteRechargeProductResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ApplyWithdrawal(ApplyWithdrawalRequest) returns (ApplyWithdrawalResponse);

View File

@ -47,6 +47,10 @@ const (
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
WalletService_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
WalletService_DeleteRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/DeleteRechargeProduct"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ApplyWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyWithdrawal"
@ -91,6 +95,10 @@ type WalletServiceClient interface {
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, error)
ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
DeleteRechargeProduct(ctx context.Context, in *DeleteRechargeProductRequest, opts ...grpc.CallOption) (*DeleteRechargeProductResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, in *ApplyWithdrawalRequest, opts ...grpc.CallOption) (*ApplyWithdrawalResponse, error)
@ -389,6 +397,46 @@ func (c *walletServiceClient) ListRechargeProducts(ctx context.Context, in *List
return out, nil
}
func (c *walletServiceClient) ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAdminRechargeProductsResponse)
err := c.cc.Invoke(ctx, WalletService_ListAdminRechargeProducts_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RechargeProductResponse)
err := c.cc.Invoke(ctx, WalletService_CreateRechargeProduct_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RechargeProductResponse)
err := c.cc.Invoke(ctx, WalletService_UpdateRechargeProduct_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) DeleteRechargeProduct(ctx context.Context, in *DeleteRechargeProductRequest, opts ...grpc.CallOption) (*DeleteRechargeProductResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteRechargeProductResponse)
err := c.cc.Invoke(ctx, WalletService_DeleteRechargeProduct_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDiamondExchangeConfigResponse)
@ -503,6 +551,10 @@ type WalletServiceServer interface {
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*RechargeProductResponse, error)
DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ApplyWithdrawal(context.Context, *ApplyWithdrawalRequest) (*ApplyWithdrawalResponse, error)
@ -605,6 +657,18 @@ func (UnimplementedWalletServiceServer) GetWalletValueSummary(context.Context, *
func (UnimplementedWalletServiceServer) ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeProducts not implemented")
}
func (UnimplementedWalletServiceServer) ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAdminRechargeProducts not implemented")
}
func (UnimplementedWalletServiceServer) CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateRechargeProduct not implemented")
}
func (UnimplementedWalletServiceServer) UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*RechargeProductResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateRechargeProduct not implemented")
}
func (UnimplementedWalletServiceServer) DeleteRechargeProduct(context.Context, *DeleteRechargeProductRequest) (*DeleteRechargeProductResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteRechargeProduct not implemented")
}
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
}
@ -1154,6 +1218,78 @@ func _WalletService_ListRechargeProducts_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListAdminRechargeProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAdminRechargeProductsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListAdminRechargeProducts(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListAdminRechargeProducts_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListAdminRechargeProducts(ctx, req.(*ListAdminRechargeProductsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreateRechargeProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateRechargeProductRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).CreateRechargeProduct(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_CreateRechargeProduct_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).CreateRechargeProduct(ctx, req.(*CreateRechargeProductRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_UpdateRechargeProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateRechargeProductRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).UpdateRechargeProduct(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_UpdateRechargeProduct_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).UpdateRechargeProduct(ctx, req.(*UpdateRechargeProductRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_DeleteRechargeProduct_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteRechargeProductRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DeleteRechargeProduct(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DeleteRechargeProduct_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DeleteRechargeProduct(ctx, req.(*DeleteRechargeProductRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiamondExchangeConfigRequest)
if err := dec(in); err != nil {
@ -1417,6 +1553,22 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRechargeProducts",
Handler: _WalletService_ListRechargeProducts_Handler,
},
{
MethodName: "ListAdminRechargeProducts",
Handler: _WalletService_ListAdminRechargeProducts_Handler,
},
{
MethodName: "CreateRechargeProduct",
Handler: _WalletService_CreateRechargeProduct_Handler,
},
{
MethodName: "UpdateRechargeProduct",
Handler: _WalletService_UpdateRechargeProduct_Handler,
},
{
MethodName: "DeleteRechargeProduct",
Handler: _WalletService_DeleteRechargeProduct_Handler,
},
{
MethodName: "GetDiamondExchangeConfig",
Handler: _WalletService_GetDiamondExchangeConfig_Handler,

View File

@ -153,6 +153,7 @@ VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`。
资源组继续复用现有资源组能力:
- 头像框:`avatar_frame`
- 资料卡:`profile_card`
- 座驾:`vehicle`
- 聊天气泡:`chat_bubble`
- 徽章:`badge`

View File

@ -0,0 +1,125 @@
# Flutter App 配置与内购对接
本文只描述当前 App 端需要直接调用的 gateway HTTP 接口。所有业务接口都返回统一外壳:
```json
{
"code": "OK",
"message": "ok",
"request_id": "server-generated-request-id",
"data": {}
}
```
## 1. 检查 App 更新
`GET /api/v1/app/version`
公开接口,不需要登录。客户端必须传平台,建议同时传当前编译版本号。
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-App-Platform` | 是 | `android``ios`。也可以用 query `platform` 覆盖。 |
| `X-App-Build-Number` | 建议 | 当前客户端编译版本号。也可以用 query `build_number` 覆盖。 |
| `X-App-Version` | 否 | 当前客户端展示版本号,只用于客户端侧日志排查。 |
示例:
```http
GET /api/v1/app/version?platform=android&build_number=100
X-App-Code: lalu
X-App-Platform: android
X-App-Build-Number: 100
```
响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"app_code": "lalu",
"platform": "android",
"version": "1.2.0",
"build_number": 120,
"force_update": true,
"download_url": "https://example.com/lalu.apk",
"description": "修复已知问题",
"has_update": true,
"current_build_number": 100,
"updated_at_ms": 1710000000000,
"server_time_ms": 1710000001234
}
}
```
Flutter 处理规则:
- `has_update=false`:不展示升级弹窗。
- `has_update=true && force_update=false`:展示普通升级弹窗,允许关闭。
- `has_update=true && force_update=true`:展示强更弹窗,不允许进入主流程。
- `download_url` 由后台配置。Android 通常打开 APK 下载页iOS 通常打开 App Store 链接。
- 服务端只用 `build_number` 判断新旧,不解析 `version` 语义版本。
## 2. 获取内购商品列表
`GET /api/v1/wallet/recharge/products`
登录接口,需要 `Authorization: Bearer <access_token>`。gateway 会用当前登录用户资料里的 `region_id` 筛选商品,客户端不要自己传区域。
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | App 登录 access token。 |
| `X-App-Platform` | 是 | `android``ios`。也可以用 query `platform` 覆盖。 |
示例:
```http
GET /api/v1/wallet/recharge/products
Authorization: Bearer <access_token>
X-App-Platform: ios
```
响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"channels": ["apple"],
"products": [
{
"product_id": 11,
"product_code": "iap_ios_1500",
"product_name": "1500 Coins",
"description": "coin pack",
"platform": "ios",
"channel": "apple",
"currency_code": "USDT",
"coin_amount": 1500,
"amount_micro": 1500000,
"region_ids": [2002],
"resource_asset_type": "COIN",
"status": "active",
"enabled": true
}
]
}
}
```
Flutter 处理规则:
- 只展示 `products` 里的商品;后台已经按平台、用户区域、上架状态过滤。
- `amount_micro` 是 USDT 微单位,`1 USDT = 1000000`
- 当前资源类型固定是 `COIN`,后续如果后台新增资源类型,客户端应按 `resource_asset_type` 分支处理。
- Android 使用 `channel=google` 的商品对接 Google Play BillingiOS 使用 `channel=apple` 的商品对接 StoreKit。

View File

@ -0,0 +1,137 @@
# Flutter 金币流水对接
本文只描述 Flutter App 读取当前登录用户金币流水的接口。金币余额和流水事实由 `wallet-service` 维护;客户端只展示 gateway 返回的分录投影,不在本地推算余额。
## 接口
`GET /api/v1/wallet/coin-transactions`
登录接口,需要 `Authorization: Bearer <access_token>`。接口固定查询当前登录用户的 `COIN` 资产流水,不接受客户端传 `user_id``asset_type`
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | App 登录 access token。 |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
Query
| 字段 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- |
| `page` | 否 | `1` | 页码,从 1 开始。 |
| `page_size` | 否 | `30` | 每页数量。首屏不传时返回最近 30 条。 |
排序固定为 `created_at_ms DESC, entry_id DESC`,即最新流水在前。分页字段使用通用 `page` / `page_size`,不要使用业务专属分页名。
示例:
```http
GET /api/v1/wallet/coin-transactions?page=1&page_size=30
Authorization: Bearer <access_token>
X-App-Code: lalu
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"entry_id": 101,
"transaction_id": "wtx_abc",
"biz_type": "coin_seller_transfer",
"asset_type": "COIN",
"available_delta": 3000,
"frozen_delta": 0,
"available_after": 12800,
"frozen_after": 0,
"counterparty_user_id": 90001,
"room_id": "",
"created_at_ms": 1710000000000
}
],
"total": 48,
"page": 1,
"page_size": 30
}
}
```
字段规则:
| 字段 | 说明 |
| --- | --- |
| `available_delta` | 本次可用金币变化;正数是收入,负数是支出。 |
| `available_after` | 本条流水落账后的可用金币余额,余额展示以该字段为准。 |
| `biz_type` | 业务类型,例如 `coin_seller_transfer``gift_debit``task_reward``game_credit``game_debit``game_refund``vip_purchase`。客户端可本地映射展示文案。 |
| `counterparty_user_id` | 对手用户长 ID没有对手方时为 `0`。 |
| `room_id` | 房间相关流水的房间 ID非房间流水为空字符串。 |
| `created_at_ms` | Unix epoch milliseconds展示层按用户本地时区格式化。 |
## Flutter 解析示例
```dart
class CoinTransactionPage {
CoinTransactionPage({
required this.items,
required this.total,
required this.page,
required this.pageSize,
});
final List<CoinTransaction> items;
final int total;
final int page;
final int pageSize;
factory CoinTransactionPage.fromJson(Map<String, dynamic> json) {
return CoinTransactionPage(
items: (json['items'] as List<dynamic>? ?? const [])
.map((item) => CoinTransaction.fromJson(item as Map<String, dynamic>))
.toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
page: (json['page'] as num?)?.toInt() ?? 1,
pageSize: (json['page_size'] as num?)?.toInt() ?? 30,
);
}
}
class CoinTransaction {
CoinTransaction({
required this.entryId,
required this.transactionId,
required this.bizType,
required this.availableDelta,
required this.availableAfter,
required this.createdAtMs,
});
final int entryId;
final String transactionId;
final String bizType;
final int availableDelta;
final int availableAfter;
final int createdAtMs;
bool get isIncome => availableDelta > 0;
int get amount => availableDelta.abs();
factory CoinTransaction.fromJson(Map<String, dynamic> json) {
return CoinTransaction(
entryId: (json['entry_id'] as num).toInt(),
transactionId: json['transaction_id'] as String? ?? '',
bizType: json['biz_type'] as String? ?? '',
availableDelta: (json['available_delta'] as num?)?.toInt() ?? 0,
availableAfter: (json['available_after'] as num?)?.toInt() ?? 0,
createdAtMs: (json['created_at_ms'] as num?)?.toInt() ?? 0,
);
}
}
```
接口调用时,先解析外层 envelope只有 `code == "OK"` 时读取 `data`;其他 `code` 按登录态、参数错误或服务错误处理,并把 `request_id` 记录到客户端日志。

View File

@ -339,6 +339,45 @@ paths:
description: 查询成功,`data` 返回 App 启动状态机需要的轻量配置摘要。
schema:
$ref: "#/definitions/AppBootstrapEnvelope"
/api/v1/app/version:
get:
tags:
- app-config
summary: 获取 App 最新版本信息
operationId: getAppVersion
description: 公开读接口gateway 根据 App 平台返回后台 APP配置/版本管理 中最高 `build_number` 版本,并按客户端当前 `build_number` 计算是否需要提示更新。
parameters:
- name: platform
in: query
required: false
type: string
enum: [android, ios]
description: 可选平台覆盖值;不传时读取 `X-App-Platform` / `X-Platform`。
- name: build_number
in: query
required: false
type: integer
format: int64
description: 当前客户端编译版本号;不传时读取 `X-App-Build-Number` / `X-Build-Number`。
- name: X-App-Platform
in: header
required: false
type: string
enum: [android, ios]
- name: X-App-Build-Number
in: header
required: false
type: integer
format: int64
responses:
"200":
description: 查询成功,`data` 返回最新版本和更新判定。
schema:
$ref: "#/definitions/AppVersionEnvelope"
"400":
$ref: "#/responses/BadRequest"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/app/h5-links:
get:
tags:
@ -1036,8 +1075,8 @@ paths:
summary: 创建房间
operationId: createRoom
description: |
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`room-service 由该 actor 确定 owner 和默认 host
请求体不接收 `room_id`、`owner_user_id` 或 `host_user_id``command_id` 由客户端按创建动作生成gateway 生成内部 room_id。
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`room-service 由该 actor 确定 owner
请求体不接收 `room_id` 或 `owner_user_id``command_id` 由客户端按创建动作生成gateway 生成内部 room_id。
同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。
security:
- BearerAuth: []
@ -1429,39 +1468,6 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/host/transfer:
post:
tags:
- rooms
summary: 转移房间主持人
operationId: transferRoomHost
security:
- BearerAuth: []
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/TransferRoomHostRequest"
responses:
"200":
description: 主持人转移成功,`data` 返回最新房间快照。
schema:
$ref: "#/definitions/TransferRoomHostEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/user/mute:
post:
tags:
@ -1605,7 +1611,7 @@ paths:
in: query
required: false
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
- name: keyword
in: query
required: false
@ -1649,7 +1655,7 @@ paths:
in: query
required: false
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
- name: page
in: query
required: false
@ -1813,7 +1819,7 @@ paths:
in: query
required: false
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
responses:
"200":
description: 返回当前用户有效资源权益。
@ -1995,6 +2001,49 @@ paths:
$ref: "#/responses/NotFound"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/wallet/recharge/products:
get:
tags:
- wallet
summary: 获取充值商品列表
operationId: listRechargeProducts
description: gateway 根据当前登录用户资料中的 `region_id` 和 App 平台筛选已上架内购商品;平台可通过 `platform` query 覆盖,默认读取 `X-App-Platform` / `X-Platform` 请求头。
security:
- BearerAuth: []
parameters:
- name: platform
in: query
required: false
type: string
enum: [android, ios]
description: 可选平台覆盖值;不传时读取 App 平台请求头。
- name: X-App-Platform
in: header
required: false
type: string
enum: [android, ios]
description: App 平台请求头。
- name: X-Platform
in: header
required: false
type: string
enum: [android, ios]
description: 兼容平台请求头。
responses:
"200":
description: 返回当前用户区域和平台可购买的上架商品。
schema:
$ref: "#/definitions/RechargeProductListEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/wallet/coin-seller/transfer:
post:
tags:
@ -3068,6 +3117,39 @@ definitions:
type: object
additionalProperties:
type: string
AppVersionData:
type: object
properties:
app_code:
type: string
platform:
type: string
enum: [android, ios]
version:
type: string
description: 最新版本号,例如 1.2.0。
build_number:
type: integer
format: int64
description: 最新编译版本号App 使用它和本地 build number 比较。
force_update:
type: boolean
description: 当前客户端落后且后台最新版本要求强更时为 true。
download_url:
type: string
description:
type: string
has_update:
type: boolean
current_build_number:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
server_time_ms:
type: integer
format: int64
CompleteOnboardingRequest:
type: object
required:
@ -3156,7 +3238,7 @@ definitions:
type: string
resource_type:
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
name:
type: string
status:
@ -3209,7 +3291,7 @@ definitions:
description: 资源 ID按字符串返回避免 64 位整数在客户端丢精度。
resource_type:
type: string
enum: [avatar_frame, coin, vehicle, chat_bubble, badge, floating_screen, gift]
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
name:
type: string
asset_url:
@ -3490,6 +3572,67 @@ definitions:
entitlement_id:
type: string
description: 可选;不传时服务端自动选择当前用户该资源最新有效权益。
RechargeProductData:
type: object
properties:
product_id:
type: integer
format: int64
product_code:
type: string
product_name:
type: string
description:
type: string
platform:
type: string
enum: [android, ios]
channel:
type: string
enum: [google, apple]
currency_code:
type: string
example: USDT
coin_amount:
type: integer
format: int64
amount_minor:
type: integer
format: int64
amount_micro:
type: integer
format: int64
description: USDT 微单位金额1 USDT = 1000000。
policy_version:
type: string
region_ids:
type: array
items:
type: integer
format: int64
resource_asset_type:
type: string
example: COIN
status:
type: string
enum: [active, disabled]
enabled:
type: boolean
sort_order:
type: integer
format: int32
RechargeProductListData:
type: object
properties:
channels:
type: array
description: 当前结果涉及的充值渠道,例如 google、apple。
items:
type: string
products:
type: array
items:
$ref: "#/definitions/RechargeProductData"
CoinSellerTransferRequest:
type: object
required:
@ -3749,17 +3892,6 @@ definitions:
format: int64
enabled:
type: boolean
TransferRoomHostRequest:
type: object
properties:
room_id:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用。
target_user_id:
type: integer
format: int64
MuteUserRequest:
type: object
properties:
@ -3892,9 +4024,6 @@ definitions:
owner_user_id:
type: integer
format: int64
host_user_id:
type: integer
format: int64
mode:
type: string
status:
@ -3952,8 +4081,6 @@ definitions:
description: 腾讯 IM 房间群 ID。当前等于 `room_id`,只代表目标群,不代表入群授权。
owner_user_id:
type: string
host_user_id:
type: string
title:
type: string
cover_url:
@ -4027,8 +4154,6 @@ definitions:
type: string
owner_user_id:
type: string
host_user_id:
type: string
mode:
type: string
status:
@ -4218,7 +4343,7 @@ definitions:
$ref: "#/definitions/RoomRankItemData"
profiles:
type: array
description: 只包含房主、主持人、当前用户、麦位用户和首屏贡献榜用户的展示资料。
description: 只包含房主、当前用户、麦位用户和首屏贡献榜用户的展示资料。
items:
$ref: "#/definitions/RoomUserProfileData"
im:
@ -4326,13 +4451,6 @@ definitions:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
TransferRoomHostResponse:
type: object
properties:
result:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
MuteUserResponse:
type: object
properties:
@ -4517,6 +4635,13 @@ definitions:
properties:
data:
$ref: "#/definitions/AppBootstrapData"
AppVersionEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/AppVersionData"
H5LinkListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -4692,13 +4817,6 @@ definitions:
properties:
data:
$ref: "#/definitions/SetRoomAdminResponse"
TransferRoomHostEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/TransferRoomHostResponse"
MuteUserEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -4727,6 +4845,13 @@ definitions:
properties:
data:
$ref: "#/definitions/SendGiftResponse"
RechargeProductListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RechargeProductListData"
CoinSellerTransferEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -249,26 +249,6 @@ paths:
$ref: "#/definitions/SetRoomAdminResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.room.v1.RoomCommandService/TransferRoomHost:
post:
tags:
- room-command
summary: 转移主持人
operationId: roomTransferRoomHost
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/TransferRoomHost
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/TransferRoomHostRequest"
responses:
"200":
description: 主持人转移成功。
schema:
$ref: "#/definitions/TransferRoomHostResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.room.v1.RoomCommandService/MuteUser:
post:
tags:
@ -583,9 +563,6 @@ definitions:
owner_user_id:
type: integer
format: int64
host_user_id:
type: integer
format: int64
mode:
type: string
status:
@ -880,21 +857,6 @@ definitions:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
TransferRoomHostRequest:
type: object
properties:
meta:
$ref: "#/definitions/RequestMeta"
target_user_id:
type: integer
format: int64
TransferRoomHostResponse:
type: object
properties:
result:
$ref: "#/definitions/CommandResult"
room:
$ref: "#/definitions/RoomSnapshot"
MuteUserRequest:
type: object
properties:
@ -1107,7 +1069,6 @@ definitions:
- room_mic_seat_locked
- room_chat_enabled_changed
- room_admin_changed
- room_host_transferred
- room_user_muted
- room_user_kicked
- room_user_unbanned

View File

@ -73,7 +73,6 @@
| POST | `/api/v1/rooms/mic/lock` | rooms | `setMicSeatLock` | 锁定或解锁麦位 |
| POST | `/api/v1/rooms/chat/enabled` | rooms | `setChatEnabled` | 开启或关闭房间公屏 |
| POST | `/api/v1/rooms/admin/set` | rooms | `setRoomAdmin` | 添加或移除房间管理员 |
| POST | `/api/v1/rooms/host/transfer` | rooms | `transferRoomHost` | 转移房间主持人 |
| POST | `/api/v1/rooms/user/mute` | rooms | `muteUser` | 设置房间内禁言 |
| POST | `/api/v1/rooms/user/kick` | rooms | `kickUser` | 踢出房间用户 |
| POST | `/api/v1/rooms/user/unban` | rooms | `unbanUser` | 解除房间 ban |
@ -93,6 +92,7 @@
| GET | `/api/v1/wallet/recharge/products` | wallet | `listRechargeProducts` | 查询充值商品和渠道 |
| GET | `/api/v1/wallet/diamond-exchange/config` | wallet | `getDiamondExchangeConfig` | 查询钻石兑换配置 |
| POST | `/api/v1/wallet/withdrawals/apply` | wallet | `applyWithdrawal` | 提交美元余额提现申请 |
| GET | `/api/v1/wallet/coin-transactions` | wallet | `listCoinTransactions` | 分页查询当前用户金币流水 |
| GET | `/api/v1/wallet/transactions` | wallet | `listWalletTransactions` | 分页查询钱包流水 |
| POST | `/api/v1/wallet/coin-seller/transfer` | wallet | `transferCoinFromSeller` | 币商给玩家转金币 |
| GET | `/api/v1/vip/me` | vip | `getMyVIP` | 查询当前 VIP 状态 |
@ -150,6 +150,7 @@
| PATCH | `/api/v1/admin/game/games/{game_id}` | admin | `updateCatalog` | 更新游戏目录 |
| PATCH | `/api/v1/admin/game/games/{game_id}/status` | admin | `setGameStatus` | 设置游戏状态 |
| GET | `/api/v1/admin/hosts` | admin | `listHosts` | listHosts |
| GET | `/api/v1/admin/operations/coin-ledger` | admin | `listCoinLedger` | listCoinLedger |
| GET | `/api/v1/admin/payment/recharge-bills` | admin | `listRechargeBills` | listRechargeBills |
| GET | `/api/v1/admin/regions` | admin | `listRegions` | listRegions |
| POST | `/api/v1/admin/regions` | admin | `createRegion` | createRegion |
@ -290,7 +291,6 @@
| POST | `/hyapp.room.v1.RoomCommandService/SetMicSeatLock` | room-command | `roomSetMicSeatLock` | 锁定或解锁麦位 |
| POST | `/hyapp.room.v1.RoomCommandService/SetChatEnabled` | room-command | `roomSetChatEnabled` | 开启或关闭公屏 |
| POST | `/hyapp.room.v1.RoomCommandService/SetRoomAdmin` | room-command | `roomSetRoomAdmin` | 添加或移除房间管理员 |
| POST | `/hyapp.room.v1.RoomCommandService/TransferRoomHost` | room-command | `roomTransferRoomHost` | 转移主持人 |
| POST | `/hyapp.room.v1.RoomCommandService/MuteUser` | room-command | `roomMuteUser` | 设置房间内禁言 |
| POST | `/hyapp.room.v1.RoomCommandService/KickUser` | room-command | `roomKickUser` | 踢出房间用户 |
| POST | `/hyapp.room.v1.RoomCommandService/UnbanUser` | room-command | `roomUnbanUser` | 解除房间 ban |

View File

@ -0,0 +1,124 @@
# 注册奖励 Flutter 对接
## 边界
注册奖励由服务端在三方注册成功后自动发放。Flutter 端不需要、也不能主动发放奖励,只需要在登录态下查询当前用户是否仍满足领取条件,用于展示入口、提示或埋点。
服务端判断条件:
- 用户没有成功领取过注册奖励。
- 注册奖励配置已开启且奖励配置有效。
- UTC 当天每日限制份数未用完;`daily_limit=0` 表示不限量。
## 查询接口
`GET /api/v1/activities/registration-reward/eligibility`
请求头:
```http
Authorization: Bearer <access_token>
X-App-Code: lalu
```
响应仍使用 gateway 统一 envelope
```json
{
"code": 0,
"message": "ok",
"request_id": "req_xxx",
"data": {
"can_receive": false,
"reason": "already_claimed",
"enabled": true,
"reward_type": "coin",
"coin_amount": 100,
"resource_group_id": 0,
"daily_limit": 1000,
"daily_used": 328,
"daily_remaining": 672,
"reward_day": "2026-05-12",
"server_time_ms": 1778563200000
}
}
```
`reason` 取值:
- `eligible`: 当前用户符合条件。
- `already_claimed`: 用户已经领取或正在发放中。
- `daily_limit_reached`: UTC 当天份数已用完。
- `disabled`: 后台关闭注册奖励。
- `not_configured`: 后台还没有配置注册奖励。
- `invalid_config`: 后台配置不完整,例如金币数量为 0 或资源组为空。
## Dart DTO
```dart
class RegistrationRewardEligibility {
const RegistrationRewardEligibility({
required this.canReceive,
required this.reason,
required this.enabled,
required this.rewardType,
required this.coinAmount,
required this.resourceGroupId,
required this.dailyLimit,
required this.dailyUsed,
required this.dailyRemaining,
required this.rewardDay,
required this.serverTimeMs,
});
final bool canReceive;
final String reason;
final bool enabled;
final String rewardType;
final int coinAmount;
final int resourceGroupId;
final int dailyLimit;
final int dailyUsed;
final int dailyRemaining;
final String rewardDay;
final int serverTimeMs;
factory RegistrationRewardEligibility.fromJson(Map<String, dynamic> json) {
return RegistrationRewardEligibility(
canReceive: json['can_receive'] == true,
reason: json['reason'] as String? ?? '',
enabled: json['enabled'] == true,
rewardType: json['reward_type'] as String? ?? '',
coinAmount: (json['coin_amount'] as num? ?? 0).toInt(),
resourceGroupId: (json['resource_group_id'] as num? ?? 0).toInt(),
dailyLimit: (json['daily_limit'] as num? ?? 0).toInt(),
dailyUsed: (json['daily_used'] as num? ?? 0).toInt(),
dailyRemaining: (json['daily_remaining'] as num? ?? 0).toInt(),
rewardDay: json['reward_day'] as String? ?? '',
serverTimeMs: (json['server_time_ms'] as num? ?? 0).toInt(),
);
}
}
```
## 调用示例
```dart
Future<RegistrationRewardEligibility> fetchRegistrationRewardEligibility(
ApiClient api,
) async {
final envelope = await api.get('/api/v1/activities/registration-reward/eligibility');
if (envelope.code != 0) {
throw ApiException(envelope.code, envelope.message);
}
return RegistrationRewardEligibility.fromJson(
envelope.data as Map<String, dynamic>,
);
}
```
## 客户端使用建议
新用户登录成功后可以调用一次该接口刷新展示状态;如果 `can_receive=false``reason=already_claimed`,说明服务端已经成功发放或发放中,不要重复提示用户手动领取。
`reward_day` 按 UTC 日期返回,不使用设备时区;客户端只做展示,不要用本地日期自行判断每日份数。

View File

@ -35,7 +35,7 @@
| Concept | Meaning |
| --- | --- |
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、资料卡、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
| `resource_group_item` | 资源组成员。`item_type=resource` 引用资源库资源;`item_type=wallet_asset` 直接配置 `COIN``DIAMOND` 数量。 |
| `gift_config` | 礼物配置是一个绑定了 `resource_type=gift` 资源的业务配置,并额外拥有礼物类型、有效期、特效、价格、收费资产、热度和主播积分规则。 |
@ -48,6 +48,7 @@
| Type | Example | Grant Result |
| --- | --- | --- |
| `avatar_frame` | 头像框 | 写入 `user_resource_entitlements` |
| `profile_card` | 资料卡 | 写入 `user_resource_entitlements` |
| `coin` | 金币包 | `wallet_accounts.asset_type=COIN` 入账 |
| `vehicle` | 座驾 | 写入 `user_resource_entitlements` |
| `chat_bubble` | 气泡 | 写入 `user_resource_entitlements` |
@ -424,7 +425,7 @@ POST /api/v1/users/me/resources/{resource_id}/equip
装备资源的边界:
- `equip` 只适用于头像框、座驾、气泡、徽章等可装备资源。
- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章等可装备资源。
- `floating_screen``gift``coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity`
- `user-service.users.avatar` 仍然只是头像 URL不承载头像框。
- 当前装备状态放在 `wallet-service` resource domain 的 `user_resource_equipment` 表,避免污染用户主数据。

View File

@ -28,6 +28,8 @@ const (
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member"
// kickUserCommand 让指定账号现有 IM 登录态失效;被封禁用户需要立即从腾讯 IM SDK 下线。
kickUserCommand = "v4/im_open_login_svc/kick"
)
// RESTConfig 保存服务端调用腾讯云 IM REST API 所需配置。
@ -278,6 +280,22 @@ func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, u
return c.DeleteGroupMember(ctx, roomID, userID)
}
// KickUser 失效指定用户的腾讯云 IM 登录态。
// 该接口不会禁止未来用新 UserSig 登录,因此调用方必须先在业务状态和 token denylist 上完成封禁。
func (c *RESTClient) KickUser(ctx context.Context, userID int64) error {
if userID <= 0 {
return fmt.Errorf("kick user is incomplete")
}
request := kickUserRequest{UserID: FormatUserID(userID)}
var response restResponse
if err := c.post(ctx, kickUserCommand, request, &response); err != nil {
return err
}
return response.err()
}
// FormatUserID 把内部不可变 user_id 映射成腾讯云账号字符串。
func FormatUserID(userID int64) string {
// 十进制 user_id 跨服务稳定,也避免把可变 display_user_id 暴露给 IM 账号体系。
@ -389,6 +407,10 @@ type deleteGroupMemberRequest struct {
Reason string `json:"Reason,omitempty"`
}
type kickUserRequest struct {
UserID string `json:"UserID"`
}
type messageElement struct {
MsgType string `json:"MsgType"`
MsgContent customMsgContent `json:"MsgContent"`

View File

@ -176,6 +176,31 @@ func TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest(t *testing.T) {
}
}
// TestRESTClientKickUserBuildsTencentRequest 锁定封禁链路使用腾讯云 IM 失效账号登录态接口。
func TestRESTClientKickUserBuildsTencentRequest(t *testing.T) {
var capturedPath string
var capturedBody map[string]any
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
capturedPath = request.URL.Path
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
return okRESTResponse(), nil
})
if err := client.KickUser(context.Background(), 10002); err != nil {
t.Fatalf("KickUser failed: %v", err)
}
if capturedPath != "/"+kickUserCommand {
t.Fatalf("path mismatch: got %q", capturedPath)
}
if capturedBody["UserID"] != "10002" {
t.Fatalf("unexpected kick body: %+v", capturedBody)
}
}
func newTestRESTClient(t *testing.T, roundTrip roundTripFunc) *RESTClient {
t.Helper()

View File

@ -0,0 +1,211 @@
// Package tencentrtc contains Tencent RTC token and server-side room APIs.
package tencentrtc
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
// DefaultRESTEndpoint is the Tencent Cloud API endpoint for TRTC room management.
DefaultRESTEndpoint = "trtc.tencentcloudapi.com"
// DefaultRESTRegion is accepted by TRTC room management APIs and keeps local config explicit.
DefaultRESTRegion = "ap-guangzhou"
trtcServiceName = "trtc"
trtcAPIVersion = "2019-07-22"
removeUserByStrRoomAction = "RemoveUserByStrRoomId"
tencentCloudTC3Algorithm = "TC3-HMAC-SHA256"
tencentCloudTC3RequestType = "tc3_request"
)
// RESTConfig describes the Tencent Cloud API credentials used for RTC room management.
type RESTConfig struct {
Enabled bool
// SDKAppID is the TRTC application ID that owns the room.
SDKAppID int64
// SecretID/SecretKey are Tencent Cloud API credentials with permission on the TRTC SDKAppID.
SecretID string
SecretKey string
// Region is required by Tencent Cloud API even though room IDs remain application-global.
Region string
// Endpoint defaults to trtc.tencentcloudapi.com.
Endpoint string
// HTTPClient lets tests inject a fake transport and lets services bound public network latency.
HTTPClient *http.Client
// Now is injectable so signature tests do not depend on wall clock time.
Now func() time.Time
}
// RESTClient wraps Tencent Cloud API 3.0 calls needed by room-service.
type RESTClient struct {
cfg RESTConfig
httpClient *http.Client
now func() time.Time
}
// NewRESTClient validates the API credential boundary and returns a reusable client.
func NewRESTClient(cfg RESTConfig) (*RESTClient, error) {
cfg.SecretID = strings.TrimSpace(cfg.SecretID)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.Region = strings.TrimSpace(cfg.Region)
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.SDKAppID <= 0 || cfg.SecretID == "" || cfg.SecretKey == "" {
return nil, fmt.Errorf("tencent rtc rest config is incomplete")
}
if cfg.Region == "" {
cfg.Region = DefaultRESTRegion
}
if cfg.Endpoint == "" {
cfg.Endpoint = DefaultRESTEndpoint
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
if cfg.Now == nil {
cfg.Now = func() time.Time { return time.Now().UTC() }
}
return &RESTClient{cfg: cfg, httpClient: cfg.HTTPClient, now: cfg.Now}, nil
}
// RemoveUserByStrRoomID removes a user from a string-ID TRTC room.
// It only affects the live RTC room; room-service must update Room Cell state before calling this.
func (c *RESTClient) RemoveUserByStrRoomID(ctx context.Context, roomID string, userID int64) error {
roomID = strings.TrimSpace(roomID)
if roomID == "" || userID <= 0 {
return fmt.Errorf("rtc remove user is incomplete")
}
payload := removeUserByStrRoomRequest{
SDKAppID: c.cfg.SDKAppID,
RoomID: roomID,
UserIDs: []string{strconv.FormatInt(userID, 10)},
}
return c.post(ctx, removeUserByStrRoomAction, payload)
}
func (c *RESTClient) post(ctx context.Context, action string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+strings.TrimSuffix(c.cfg.Endpoint, "/"), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("Host", c.cfg.Endpoint)
request.Header.Set("X-TC-Action", action)
request.Header.Set("X-TC-Version", trtcAPIVersion)
request.Header.Set("X-TC-Region", c.cfg.Region)
c.sign(request, body, action)
response, err := c.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("tencent rtc http status %d: %s", response.StatusCode, string(responseBody))
}
var cloudResp cloudAPIResponse
if err := json.Unmarshal(responseBody, &cloudResp); err != nil {
return err
}
if cloudResp.Response.Error != nil {
return fmt.Errorf("tencent rtc rest failed: code=%s message=%s", cloudResp.Response.Error.Code, cloudResp.Response.Error.Message)
}
return nil
}
func (c *RESTClient) sign(request *http.Request, body []byte, action string) {
now := c.now().UTC()
timestamp := now.Unix()
date := now.Format("2006-01-02")
request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10))
hashedPayload := sha256Hex(body)
canonicalHeaders := "content-type:" + strings.ToLower(request.Header.Get("Content-Type")) + "\n" + "host:" + c.cfg.Endpoint + "\n"
signedHeaders := "content-type;host"
canonicalRequest := strings.Join([]string{
http.MethodPost,
"/",
"",
canonicalHeaders,
signedHeaders,
hashedPayload,
}, "\n")
credentialScope := date + "/" + trtcServiceName + "/" + tencentCloudTC3RequestType
stringToSign := strings.Join([]string{
tencentCloudTC3Algorithm,
strconv.FormatInt(timestamp, 10),
credentialScope,
sha256Hex([]byte(canonicalRequest)),
}, "\n")
signature := hex.EncodeToString(hmacSHA256(tc3SigningKey(c.cfg.SecretKey, date), stringToSign))
authorization := fmt.Sprintf(
"%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
tencentCloudTC3Algorithm,
c.cfg.SecretID,
credentialScope,
signedHeaders,
signature,
)
request.Header.Set("Authorization", authorization)
_ = action
}
func tc3SigningKey(secretKey string, date string) []byte {
secretDate := hmacSHA256([]byte("TC3"+secretKey), date)
secretService := hmacSHA256(secretDate, trtcServiceName)
return hmacSHA256(secretService, tencentCloudTC3RequestType)
}
func hmacSHA256(key []byte, value string) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(value))
return mac.Sum(nil)
}
func sha256Hex(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}
type removeUserByStrRoomRequest struct {
SDKAppID int64 `json:"SdkAppId"`
RoomID string `json:"RoomId"`
UserIDs []string `json:"UserIds"`
}
type cloudAPIResponse struct {
Response struct {
RequestID string `json:"RequestId"`
Error *cloudAPIError `json:"Error,omitempty"`
} `json:"Response"`
}
type cloudAPIError struct {
Code string `json:"Code"`
Message string `json:"Message"`
}

View File

@ -0,0 +1,63 @@
package tencentrtc
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
// TestRESTClientRemoveUserByStrRoomIDBuildsTencentCloudRequest 锁定 TRTC 字符串房间踢人的 API 3.0 请求形态。
func TestRESTClientRemoveUserByStrRoomIDBuildsTencentCloudRequest(t *testing.T) {
var capturedBody map[string]any
var capturedHeader http.Header
client, err := NewRESTClient(RESTConfig{
SDKAppID: 1400000001,
SecretID: "AKIDEXAMPLE",
SecretKey: "secret",
Region: "ap-guangzhou",
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
capturedHeader = request.Header.Clone()
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"Response":{"RequestId":"req-1"}}`))),
Header: make(http.Header),
}, nil
})},
Now: func() time.Time { return time.Unix(1_700_000_000, 0).UTC() },
})
if err != nil {
t.Fatalf("NewRESTClient failed: %v", err)
}
if err := client.RemoveUserByStrRoomID(context.Background(), "room_1001", 10002); err != nil {
t.Fatalf("RemoveUserByStrRoomID failed: %v", err)
}
if capturedBody["SdkAppId"].(float64) != 1400000001 || capturedBody["RoomId"] != "room_1001" {
t.Fatalf("unexpected remove user body: %+v", capturedBody)
}
users, ok := capturedBody["UserIds"].([]any)
if !ok || len(users) != 1 || users[0] != "10002" {
t.Fatalf("unexpected user ids: %+v", capturedBody)
}
if capturedHeader.Get("X-TC-Action") != removeUserByStrRoomAction || capturedHeader.Get("X-TC-Version") != trtcAPIVersion {
t.Fatalf("unexpected tencent cloud headers: %+v", capturedHeader)
}
if !strings.HasPrefix(capturedHeader.Get("Authorization"), tencentCloudTC3Algorithm+" Credential=AKIDEXAMPLE/") {
t.Fatalf("authorization header missing TC3 credential: %s", capturedHeader.Get("Authorization"))
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}

View File

@ -28,6 +28,7 @@ import (
appusermodule "hyapp-admin-server/internal/modules/appuser"
auditmodule "hyapp-admin-server/internal/modules/audit"
authmodule "hyapp-admin-server/internal/modules/auth"
coinledgermodule "hyapp-admin-server/internal/modules/coinledger"
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
@ -39,6 +40,7 @@ import (
notificationmodule "hyapp-admin-server/internal/modules/notification"
paymentmodule "hyapp-admin-server/internal/modules/payment"
rbacmodule "hyapp-admin-server/internal/modules/rbac"
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource"
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
searchmodule "hyapp-admin-server/internal/modules/search"
@ -201,27 +203,29 @@ func main() {
objectUploader = uploader
}
handlers := router.Handlers{
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), userDB, walletDB, cfg, auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
Menu: menumodule.New(store, auditHandler),
Notification: notificationmodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn)),
RBAC: rbacmodule.New(store, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, auditHandler),
Search: searchmodule.New(store),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
Menu: menumodule.New(store, auditHandler),
Notification: notificationmodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, auditHandler),
Search: searchmodule.New(store),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
}
engine := router.New(cfg, auth, handlers)

View File

@ -267,7 +267,6 @@
| `room:mic` | `button` | 上下麦、换麦、锁麦 |
| `room:chat` | `button` | 公屏开关 |
| `room:admin` | `button` | 设置房间管理员 |
| `room:host-transfer` | `button` | 转移主持 |
| `room:mute` | `button` | 禁言、解除禁言 |
| `room:kick` | `button` | 踢出房间 |
| `room:unban` | `button` | 房间解封用户 |

View File

@ -13,18 +13,23 @@ type Client interface {
ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error)
UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error)
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
}
type GRPCClient struct {
taskClient activityv1.AdminTaskServiceClient
broadcastClient activityv1.BroadcastServiceClient
taskClient activityv1.AdminTaskServiceClient
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
broadcastClient activityv1.BroadcastServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{
taskClient: activityv1.NewAdminTaskServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
taskClient: activityv1.NewAdminTaskServiceClient(conn),
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
}
}
@ -40,6 +45,18 @@ func (c *GRPCClient) SetTaskDefinitionStatus(ctx context.Context, req *activityv
return c.taskClient.SetTaskDefinitionStatus(ctx, req)
}
func (c *GRPCClient) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error) {
return c.registrationRewardClient.GetRegistrationRewardConfig(ctx, req)
}
func (c *GRPCClient) UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error) {
return c.registrationRewardClient.UpdateRegistrationRewardConfig(ctx, req)
}
func (c *GRPCClient) ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error) {
return c.registrationRewardClient.ListRegistrationRewardClaims(ctx, req)
}
func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
}

View File

@ -12,6 +12,7 @@ import (
type Client interface {
GetUser(ctx context.Context, req GetUserRequest) (*User, error)
SetUserStatus(ctx context.Context, req SetUserStatusRequest) (*SetUserStatusResult, error)
ResolveDisplayUserID(ctx context.Context, req ResolveDisplayUserIDRequest) (*UserIdentity, error)
ListCountries(ctx context.Context, req ListCountriesRequest) ([]*Country, error)
UpdateCountry(ctx context.Context, req UpdateCountryRequest) (*Country, error)
@ -36,6 +37,30 @@ type GetUserRequest struct {
UserID int64
}
type SetUserStatusRequest struct {
RequestID string
Caller string
TargetUserID int64
Status string
OperatorType string
OperatorUserID int64
Reason string
}
type SetUserStatusResult struct {
User *User `json:"user"`
RevokedSessionCount int64 `json:"revokedSessionCount"`
AccessTokenRevoked bool `json:"accessTokenRevoked"`
AccessTokenError string `json:"accessTokenRevokeError,omitempty"`
IMKicked bool `json:"imKicked"`
IMKickError string `json:"imKickError,omitempty"`
RoomEvicted bool `json:"roomEvicted"`
RoomID string `json:"roomId,omitempty"`
RTCKicked bool `json:"rtcKicked"`
RTCKickError string `json:"rtcKickError,omitempty"`
RoomEvictError string `json:"roomEvictError,omitempty"`
}
type ResolveDisplayUserIDRequest struct {
RequestID string
Caller string
@ -117,6 +142,46 @@ func (c *GRPCClient) GetUser(ctx context.Context, req GetUserRequest) (*User, er
return fromProtoUser(resp.GetUser()), nil
}
func (c *GRPCClient) SetUserStatus(ctx context.Context, req SetUserStatusRequest) (*SetUserStatusResult, error) {
resp, err := c.client.SetUserStatus(ctx, &userv1.SetUserStatusRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
TargetUserId: req.TargetUserID,
Status: userStatusToProto(req.Status),
OperatorType: req.OperatorType,
OperatorUserId: req.OperatorUserID,
Reason: req.Reason,
})
if err != nil {
return nil, err
}
return &SetUserStatusResult{
User: fromProtoUser(resp.GetUser()),
RevokedSessionCount: resp.GetRevokedSessionCount(),
AccessTokenRevoked: resp.GetAccessTokenRevoked(),
AccessTokenError: resp.GetAccessTokenRevokeError(),
IMKicked: resp.GetImKicked(),
IMKickError: resp.GetImKickError(),
RoomEvicted: resp.GetRoomEvicted(),
RoomID: resp.GetRoomId(),
RTCKicked: resp.GetRtcKicked(),
RTCKickError: resp.GetRtcKickError(),
RoomEvictError: resp.GetRoomEvictError(),
}, nil
}
func userStatusToProto(status string) userv1.UserStatus {
switch status {
case "active":
return userv1.UserStatus_USER_STATUS_ACTIVE
case "disabled":
return userv1.UserStatus_USER_STATUS_DISABLED
case "banned":
return userv1.UserStatus_USER_STATUS_BANNED
default:
return userv1.UserStatus_USER_STATUS_UNSPECIFIED
}
}
func (c *GRPCClient) ResolveDisplayUserID(ctx context.Context, req ResolveDisplayUserIDRequest) (*UserIdentity, error) {
resp, err := c.identityClient.ResolveDisplayUserID(ctx, &userv1.ResolveDisplayUserIDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
@ -134,7 +199,7 @@ func fromProtoUser(user *userv1.User) *User {
}
return &User{
UserID: user.GetUserId(),
Status: user.GetStatus().String(),
Status: fromProtoStatus(user.GetStatus()),
CreatedAtMs: user.GetCreatedAtMs(),
UpdatedAtMs: user.GetUpdatedAtMs(),
DisplayUserID: user.GetDisplayUserId(),
@ -162,6 +227,19 @@ func fromProtoUser(user *userv1.User) *User {
}
}
func fromProtoStatus(status userv1.UserStatus) string {
switch status {
case userv1.UserStatus_USER_STATUS_ACTIVE:
return "active"
case userv1.UserStatus_USER_STATUS_DISABLED:
return "disabled"
case userv1.UserStatus_USER_STATUS_BANNED:
return "banned"
default:
return ""
}
}
func fromProtoUserIdentity(identity *userv1.UserIdentity) *UserIdentity {
if identity == nil {
return nil

View File

@ -29,6 +29,10 @@ type Client interface {
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error)
}
type GRPCClient struct {
@ -114,3 +118,19 @@ func (c *GRPCClient) AdminCreditCoinSellerStock(ctx context.Context, req *wallet
func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
return c.client.ListRechargeBills(ctx, req)
}
func (c *GRPCClient) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
return c.client.ListAdminRechargeProducts(ctx, req)
}
func (c *GRPCClient) CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) {
return c.client.CreateRechargeProduct(ctx, req)
}
func (c *GRPCClient) UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) {
return c.client.UpdateRechargeProduct(ctx, req)
}
func (c *GRPCClient) DeleteRechargeProduct(ctx context.Context, req *walletv1.DeleteRechargeProductRequest) (*walletv1.DeleteRechargeProductResponse, error) {
return c.client.DeleteRechargeProduct(ctx, req)
}

View File

@ -111,6 +111,23 @@ func (AppBanner) TableName() string {
return "admin_app_banners"
}
type AppVersion struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_versions_build,not null;default:lalu" json:"appCode"`
Platform string `gorm:"size:24;uniqueIndex:uk_admin_app_versions_build;index:idx_admin_app_versions_latest,not null" json:"platform"`
Version string `gorm:"size:40;not null" json:"version"`
BuildNumber int64 `gorm:"uniqueIndex:uk_admin_app_versions_build;index:idx_admin_app_versions_latest,not null" json:"buildNumber"`
ForceUpdate bool `gorm:"not null;default:false" json:"forceUpdate"`
DownloadURL string `gorm:"size:2048;not null" json:"downloadUrl"`
Description string `gorm:"type:text" json:"description"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (AppVersion) TableName() string {
return "admin_app_versions"
}
type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"userId"`

View File

@ -110,6 +110,65 @@ func (h *Handler) DeleteBanner(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListAppVersions(c *gin.Context) {
items, err := h.service.ListAppVersions(appctx.FromContext(c.Request.Context()), repository.AppVersionListOptions{
Keyword: strings.TrimSpace(c.Query("keyword")),
Platform: strings.TrimSpace(c.Query("platform")),
})
if err != nil {
response.ServerError(c, "获取版本配置失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) CreateAppVersion(c *gin.Context) {
var request appVersionRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.CreateAppVersion(appctx.FromContext(c.Request.Context()), request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "create-app-version", "admin_app_versions", "success", fmt.Sprintf("version_id=%d", item.ID))
response.Created(c, item)
}
func (h *Handler) UpdateAppVersion(c *gin.Context) {
id, ok := appVersionID(c)
if !ok {
return
}
var request appVersionRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.UpdateAppVersion(appctx.FromContext(c.Request.Context()), id, request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-app-version", "admin_app_versions", "success", fmt.Sprintf("version_id=%d", item.ID))
response.OK(c, item)
}
func (h *Handler) DeleteAppVersion(c *gin.Context) {
id, ok := appVersionID(c)
if !ok {
return
}
if err := h.service.DeleteAppVersion(appctx.FromContext(c.Request.Context()), id); err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "delete-app-version", "admin_app_versions", "success", fmt.Sprintf("version_id=%d", id))
response.OK(c, gin.H{"deleted": true})
}
func bannerID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("banner_id"), 10, 64)
if err != nil || value == 0 {
@ -119,6 +178,15 @@ func bannerID(c *gin.Context) (uint, bool) {
return uint(value), true
}
func appVersionID(c *gin.Context) (uint, bool) {
value, err := strconv.ParseUint(c.Param("version_id"), 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "version id is invalid")
return 0, false
}
return uint(value), true
}
func parseOptionalInt64(values ...string) int64 {
for _, value := range values {
value = strings.TrimSpace(value)

View File

@ -20,3 +20,12 @@ type bannerRequest struct {
CountryCode string `json:"countryCode"`
Description string `json:"description"`
}
type appVersionRequest struct {
Platform string `json:"platform" binding:"required"`
Version string `json:"version" binding:"required"`
BuildNumber int64 `json:"buildNumber" binding:"required"`
ForceUpdate bool `json:"forceUpdate"`
DownloadURL string `json:"downloadUrl" binding:"required"`
Description string `json:"description"`
}

View File

@ -18,4 +18,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/app-config/banners", middleware.RequirePermission("app-config:update"), h.CreateBanner)
protected.PUT("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.UpdateBanner)
protected.DELETE("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.DeleteBanner)
protected.GET("/admin/app-config/versions", middleware.RequirePermission("app-version:view"), h.ListAppVersions)
protected.POST("/admin/app-config/versions", middleware.RequirePermission("app-version:create"), h.CreateAppVersion)
protected.PUT("/admin/app-config/versions/:version_id", middleware.RequirePermission("app-version:update"), h.UpdateAppVersion)
protected.DELETE("/admin/app-config/versions/:version_id", middleware.RequirePermission("app-version:delete"), h.DeleteAppVersion)
}

View File

@ -54,6 +54,19 @@ type AppBanner struct {
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppVersion struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
Platform string `json:"platform"`
Version string `json:"version"`
BuildNumber int64 `json:"buildNumber"`
ForceUpdate bool `json:"forceUpdate"`
DownloadURL string `json:"downloadUrl"`
Description string `json:"description"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
func NewService(store *repository.Store) *AppConfigService {
return &AppConfigService{store: store}
}
@ -176,6 +189,57 @@ func (s *AppConfigService) DeleteBanner(appCode string, id uint) error {
return s.store.DeleteAppBanner(appctx.Normalize(appCode), id)
}
func (s *AppConfigService) ListAppVersions(appCode string, options repository.AppVersionListOptions) ([]AppVersion, error) {
options.AppCode = appctx.Normalize(appCode)
options.Platform = normalizeAppPlatform(options.Platform)
options.Keyword = strings.TrimSpace(options.Keyword)
items, err := s.store.ListAppVersions(options)
if err != nil {
return nil, err
}
out := make([]AppVersion, 0, len(items))
for _, item := range items {
out = append(out, appVersionFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreateAppVersion(appCode string, req appVersionRequest) (AppVersion, error) {
item, err := appVersionModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return AppVersion{}, err
}
if err := s.store.CreateAppVersion(&item); err != nil {
return AppVersion{}, err
}
return appVersionFromModel(item), nil
}
func (s *AppConfigService) UpdateAppVersion(appCode string, id uint, req appVersionRequest) (AppVersion, error) {
item, err := s.store.GetAppVersion(appctx.Normalize(appCode), id)
if err != nil {
return AppVersion{}, err
}
updated, err := appVersionModelFromRequest(item.AppCode, req)
if err != nil {
return AppVersion{}, err
}
item.Platform = updated.Platform
item.Version = updated.Version
item.BuildNumber = updated.BuildNumber
item.ForceUpdate = updated.ForceUpdate
item.DownloadURL = updated.DownloadURL
item.Description = updated.Description
if err := s.store.UpdateAppVersion(&item); err != nil {
return AppVersion{}, err
}
return appVersionFromModel(item), nil
}
func (s *AppConfigService) DeleteAppVersion(appCode string, id uint) error {
return s.store.DeleteAppVersion(appctx.Normalize(appCode), id)
}
func h5LinkDefinitionSet() map[string]string {
out := make(map[string]string, len(h5LinkDefinitions))
for _, definition := range h5LinkDefinitions {
@ -265,6 +329,54 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
}
}
func appVersionModelFromRequest(appCode string, req appVersionRequest) (model.AppVersion, error) {
item := model.AppVersion{
AppCode: appctx.Normalize(appCode),
Platform: normalizeAppPlatform(req.Platform),
Version: strings.TrimSpace(req.Version),
BuildNumber: req.BuildNumber,
ForceUpdate: req.ForceUpdate,
DownloadURL: strings.TrimSpace(req.DownloadURL),
Description: strings.TrimSpace(req.Description),
}
if item.Platform != "android" && item.Platform != "ios" {
return model.AppVersion{}, errors.New("app version platform is invalid")
}
if item.Version == "" || utf8.RuneCountInString(item.Version) > 40 {
return model.AppVersion{}, errors.New("app version is invalid")
}
if item.BuildNumber <= 0 {
return model.AppVersion{}, errors.New("app build number is invalid")
}
if item.DownloadURL == "" || len(item.DownloadURL) > 2048 {
return model.AppVersion{}, errors.New("app download url is invalid")
}
for _, char := range item.DownloadURL {
if unicode.IsSpace(char) {
return model.AppVersion{}, errors.New("app download url cannot contain whitespace")
}
}
if utf8.RuneCountInString(item.Description) > 1000 {
return model.AppVersion{}, errors.New("app version description is too long")
}
return item, nil
}
func appVersionFromModel(item model.AppVersion) AppVersion {
return AppVersion{
ID: item.ID,
AppCode: item.AppCode,
Platform: item.Platform,
Version: item.Version,
BuildNumber: item.BuildNumber,
ForceUpdate: item.ForceUpdate,
DownloadURL: item.DownloadURL,
Description: item.Description,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func normalizeBannerType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
@ -281,6 +393,10 @@ func normalizeBannerPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeAppPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeCountryCode(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}

View File

@ -0,0 +1,301 @@
package appuser
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
)
type AppUserBanRecord struct {
ID int64 `json:"id"`
Target AppUserBrief `json:"target"`
Operator BanOperator `json:"operator"`
OldStatus string `json:"oldStatus"`
NewStatus string `json:"newStatus"`
Reason string `json:"reason"`
RequestID string `json:"requestId"`
CreatedAtMs int64 `json:"createdAtMs"`
}
type AppUserBrief struct {
Avatar string `json:"avatar"`
DisplayUserID string `json:"displayUserId"`
UserID string `json:"userId"`
Username string `json:"username"`
}
type BanOperator struct {
Type string `json:"type"`
AdminID string `json:"adminId,omitempty"`
Account string `json:"account,omitempty"`
Avatar string `json:"avatar,omitempty"`
DisplayUserID string `json:"displayUserId,omitempty"`
Name string `json:"name,omitempty"`
UserID string `json:"userId,omitempty"`
}
type adminOperatorProfile struct {
Account string
Name string
}
func (s *Service) ListBannedUsers(ctx context.Context, query banListQuery) ([]AppUserBanRecord, int64, error) {
if s.userDB == nil {
return nil, 0, fmt.Errorf("user mysql is not configured")
}
query = normalizeBanListQuery(query)
adminIDs, err := s.lookupAdminOperatorIDs(ctx, query.OperatorKeyword)
if err != nil {
return nil, 0, err
}
whereSQL, args := banListWhereSQL(ctx, query, adminIDs)
total, err := countRows(ctx, s.userDB, whereSQL, args...)
if err != nil {
return nil, 0, err
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT l.id,
l.target_user_id,
target.current_display_user_id,
COALESCE(target.username, ''),
COALESCE(target.avatar, ''),
l.old_status,
l.new_status,
l.operator_type,
l.operator_user_id,
COALESCE(operator_user.current_display_user_id, ''),
COALESCE(operator_user.username, ''),
COALESCE(operator_user.avatar, ''),
l.reason,
l.request_id,
l.created_at_ms
%s
ORDER BY l.created_at_ms DESC, l.id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]AppUserBanRecord, 0, query.PageSize)
adminOperatorIDs := make([]int64, 0)
for rows.Next() {
item, adminOperatorID, err := scanBanRecord(rows)
if err != nil {
return nil, 0, err
}
if adminOperatorID > 0 {
adminOperatorIDs = append(adminOperatorIDs, adminOperatorID)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := s.fillAdminOperators(ctx, items, adminOperatorIDs); err != nil {
return nil, 0, err
}
return items, total, nil
}
func banListWhereSQL(ctx context.Context, query banListQuery, adminIDs []int64) (string, []any) {
appCode := appctx.FromContext(ctx)
whereSQL := `
FROM user_status_change_logs l
INNER JOIN (
SELECT app_code, target_user_id, MAX(id) AS latest_id
FROM user_status_change_logs
WHERE app_code = ? AND new_status IN ('banned', 'disabled')
GROUP BY app_code, target_user_id
) latest ON latest.app_code = l.app_code AND latest.latest_id = l.id
INNER JOIN users target ON target.app_code = l.app_code AND target.user_id = l.target_user_id
LEFT JOIN users operator_user ON l.operator_type = 'app_user' AND operator_user.app_code = l.app_code AND operator_user.user_id = l.operator_user_id
WHERE l.app_code = ? AND target.status IN ('banned', 'disabled')`
args := []any{appCode, appCode}
if query.TargetKeyword != "" {
like := "%" + query.TargetKeyword + "%"
whereSQL += " AND (CAST(target.user_id AS CHAR) LIKE ? OR target.current_display_user_id LIKE ? OR target.username LIKE ?)"
args = append(args, like, like, like)
}
if query.OperatorKeyword != "" {
like := "%" + query.OperatorKeyword + "%"
operatorClauses := []string{"(l.operator_type = 'app_user' AND (CAST(operator_user.user_id AS CHAR) LIKE ? OR operator_user.current_display_user_id LIKE ? OR operator_user.username LIKE ?))"}
args = append(args, like, like, like)
if len(adminIDs) > 0 {
operatorClauses = append(operatorClauses, "(l.operator_type = 'admin' AND l.operator_user_id IN ("+placeholders(len(adminIDs))+"))")
for _, id := range adminIDs {
args = append(args, id)
}
}
whereSQL += " AND (" + strings.Join(operatorClauses, " OR ") + ")"
}
if query.StartMs > 0 {
whereSQL += " AND l.created_at_ms >= ?"
args = append(args, query.StartMs)
}
if query.EndMs > 0 {
whereSQL += " AND l.created_at_ms < ?"
args = append(args, query.EndMs)
}
return whereSQL, args
}
func scanBanRecord(rows *sql.Rows) (AppUserBanRecord, int64, error) {
var item AppUserBanRecord
var targetUserID int64
var operatorType string
var operatorUserID int64
var operatorDisplayUserID string
var operatorUsername string
var operatorAvatar string
if err := rows.Scan(
&item.ID,
&targetUserID,
&item.Target.DisplayUserID,
&item.Target.Username,
&item.Target.Avatar,
&item.OldStatus,
&item.NewStatus,
&operatorType,
&operatorUserID,
&operatorDisplayUserID,
&operatorUsername,
&operatorAvatar,
&item.Reason,
&item.RequestID,
&item.CreatedAtMs,
); err != nil {
return AppUserBanRecord{}, 0, err
}
item.Target.UserID = strconv.FormatInt(targetUserID, 10)
item.Operator.Type = operatorType
if operatorType == "admin" {
item.Operator.AdminID = strconv.FormatInt(operatorUserID, 10)
item.Operator.Account = item.Operator.AdminID
return item, operatorUserID, nil
}
if operatorType == "app_user" && operatorUserID > 0 {
item.Operator.UserID = strconv.FormatInt(operatorUserID, 10)
item.Operator.DisplayUserID = operatorDisplayUserID
item.Operator.Name = operatorUsername
item.Operator.Avatar = operatorAvatar
}
return item, 0, nil
}
func (s *Service) lookupAdminOperatorIDs(ctx context.Context, keyword string) ([]int64, error) {
if s.adminDB == nil || strings.TrimSpace(keyword) == "" {
return nil, nil
}
like := "%" + strings.TrimSpace(keyword) + "%"
rows, err := s.adminDB.QueryContext(ctx, `
SELECT id
FROM admin_users
WHERE CAST(id AS CHAR) LIKE ? OR username LIKE ? OR name LIKE ?
LIMIT 200
`, like, like, like)
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]int64, 0)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *Service) fillAdminOperators(ctx context.Context, items []AppUserBanRecord, adminIDs []int64) error {
if s.adminDB == nil || len(adminIDs) == 0 {
return nil
}
ids := uniquePositiveInt64s(adminIDs)
if len(ids) == 0 {
return nil
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(`
SELECT id, username, name
FROM admin_users
WHERE id IN (%s)
`, placeholders(len(ids))), args...)
if err != nil {
return err
}
defer rows.Close()
profiles := make(map[int64]adminOperatorProfile, len(ids))
for rows.Next() {
var id int64
var profile adminOperatorProfile
if err := rows.Scan(&id, &profile.Account, &profile.Name); err != nil {
return err
}
profiles[id] = profile
}
if err := rows.Err(); err != nil {
return err
}
for index := range items {
if items[index].Operator.Type != "admin" {
continue
}
adminID, _ := strconv.ParseInt(items[index].Operator.AdminID, 10, 64)
profile, ok := profiles[adminID]
if !ok {
continue
}
items[index].Operator.Account = profile.Account
items[index].Operator.Name = profile.Name
}
return nil
}
func normalizeBanListQuery(query banListQuery) banListQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.TargetKeyword = strings.TrimSpace(query.TargetKeyword)
query.OperatorKeyword = strings.TrimSpace(query.OperatorKeyword)
return query
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}

View File

@ -0,0 +1,61 @@
package appuser
import (
"context"
"strings"
"testing"
)
func TestBanListWhereSQLBuildsTargetKeywordFilter(t *testing.T) {
whereSQL, args := banListWhereSQL(context.Background(), banListQuery{
TargetKeyword: "10086",
}, nil)
for _, fragment := range []string{
"target.status IN ('banned', 'disabled')",
"CAST(target.user_id AS CHAR) LIKE ?",
"target.current_display_user_id LIKE ?",
"target.username LIKE ?",
} {
if !strings.Contains(whereSQL, fragment) {
t.Fatalf("where sql missing %q: %s", fragment, whereSQL)
}
}
if len(args) != 5 {
t.Fatalf("args length = %d, want 5: %#v", len(args), args)
}
for index := 2; index < len(args); index++ {
if args[index] != "%10086%" {
t.Fatalf("target keyword arg[%d] = %#v, want %%10086%%", index, args[index])
}
}
}
func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) {
whereSQL, args := banListWhereSQL(context.Background(), banListQuery{
OperatorKeyword: "admin",
StartMs: 1000,
EndMs: 2000,
}, []int64{7, 9})
for _, fragment := range []string{
"l.operator_type = 'app_user'",
"operator_user.current_display_user_id LIKE ?",
"(l.operator_type = 'admin' AND l.operator_user_id IN (?,?))",
"l.created_at_ms >= ?",
"l.created_at_ms < ?",
} {
if !strings.Contains(whereSQL, fragment) {
t.Fatalf("where sql missing %q: %s", fragment, whereSQL)
}
}
wantArgs := []any{"lalu", "lalu", "%admin%", "%admin%", "%admin%", int64(7), int64(9), int64(1000), int64(2000)}
if len(args) != len(wantArgs) {
t.Fatalf("args length = %d, want %d: %#v", len(args), len(wantArgs), args)
}
for index := range wantArgs {
if args[index] != wantArgs[index] {
t.Fatalf("arg[%d] = %#v, want %#v", index, args[index], wantArgs[index])
}
}
}

View File

@ -23,9 +23,9 @@ type Handler struct {
audit shared.OperationLogger
}
func New(userClient userclient.Client, activityClient activityclient.Client, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler {
func New(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB, cfg config.Config, audit shared.OperationLogger) *Handler {
return &Handler{
service: NewService(userClient, activityClient, userDB, walletDB),
service: NewService(userClient, activityClient, adminDB, userDB, walletDB),
cfg: cfg,
audit: audit,
}
@ -51,6 +51,16 @@ func (h *Handler) ListLoginLogs(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListBannedUsers(c *gin.Context) {
query := parseBanListQuery(c)
items, total, err := h.service.ListBannedUsers(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取封禁列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListUserLoginLogs(c *gin.Context) {
userID, ok := parseInt64ID(c, "id")
if !ok {
@ -133,13 +143,13 @@ func (h *Handler) setUserStatus(c *gin.Context, status string, action string, su
if !ok {
return
}
user, err := h.service.SetUserStatus(c.Request.Context(), userID, status)
result, err := h.service.SetUserStatus(c.Request.Context(), userID, status, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c))
if err != nil {
writeMutationError(c, err, successMessage+"失败")
return
}
writeAppUserAuditLog(c, h.audit, action, "app_users", userID, "success", fmt.Sprintf("status=%s", status))
response.OK(c, user)
response.OK(c, result)
}
func parseListQuery(c *gin.Context) listQuery {
@ -174,6 +184,18 @@ func parseLoginLogQuery(c *gin.Context) loginLogQuery {
})
}
func parseBanListQuery(c *gin.Context) banListQuery {
options := shared.ListOptions(c)
return normalizeBanListQuery(banListQuery{
Page: options.Page,
PageSize: options.PageSize,
TargetKeyword: firstQuery(c, "target_keyword", "targetKeyword", "keyword"),
OperatorKeyword: firstQuery(c, "operator_keyword", "operatorKeyword"),
StartMs: queryInt64(c, "start_ms", "startMs"),
EndMs: queryInt64(c, "end_ms", "endMs"),
})
}
func firstQuery(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {

View File

@ -25,6 +25,15 @@ type loginLogQuery struct {
EndMs int64
}
type banListQuery struct {
Page int
PageSize int
TargetKeyword string
OperatorKeyword string
StartMs int64
EndMs int64
}
type updateUserRequest struct {
Avatar *string `json:"avatar"`
Country *string `json:"country"`

View File

@ -12,6 +12,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/app/users", middleware.RequirePermission("app-user:view"), h.ListUsers)
protected.GET("/app/users/bans", middleware.RequirePermission("app-user:view"), h.ListBannedUsers)
protected.GET("/app/users/login-logs", middleware.RequirePermission("app-user:view"), h.ListLoginLogs)
protected.GET("/app/users/:id/login-logs", middleware.RequirePermission("app-user:view"), h.ListUserLoginLogs)
protected.GET("/app/users/:id", middleware.RequirePermission("app-user:view"), h.GetUser)

View File

@ -25,6 +25,7 @@ var (
type Service struct {
userClient userclient.Client
activityClient activityclient.Client
adminDB *sql.DB
userDB *sql.DB
walletDB *sql.DB
}
@ -48,8 +49,22 @@ type AppUser struct {
Username string `json:"username"`
}
func NewService(userClient userclient.Client, activityClient activityclient.Client, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, activityClient: activityClient, userDB: userDB, walletDB: walletDB}
type SetUserStatusResult struct {
User AppUser `json:"user"`
RevokedSessionCount int64 `json:"revokedSessionCount"`
AccessTokenRevoked bool `json:"accessTokenRevoked"`
AccessTokenError string `json:"accessTokenRevokeError,omitempty"`
IMKicked bool `json:"imKicked"`
IMKickError string `json:"imKickError,omitempty"`
RoomEvicted bool `json:"roomEvicted"`
RoomID string `json:"roomId,omitempty"`
RTCKicked bool `json:"rtcKicked"`
RTCKickError string `json:"rtcKickError,omitempty"`
RoomEvictError string `json:"roomEvictError,omitempty"`
}
func NewService(userClient userclient.Client, activityClient activityclient.Client, adminDB *sql.DB, userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userClient: userClient, activityClient: activityClient, adminDB: adminDB, userDB: userDB, walletDB: walletDB}
}
func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, int64, error) {
@ -336,46 +351,44 @@ func (s *Service) removeOldRegionBroadcastMemberBestEffort(ctx context.Context,
}
}
func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string) (AppUser, error) {
if s.userDB == nil {
return AppUser{}, fmt.Errorf("user mysql is not configured")
}
func (s *Service) SetUserStatus(ctx context.Context, userID int64, status string, operatorUserID int64, requestID string) (SetUserStatusResult, error) {
status = strings.ToLower(strings.TrimSpace(status))
if status != "active" && status != "banned" && status != "disabled" {
return AppUser{}, fmt.Errorf("%w: 状态不正确", ErrInvalidArgument)
return SetUserStatusResult{}, fmt.Errorf("%w: 状态不正确", ErrInvalidArgument)
}
tx, err := s.userDB.BeginTx(ctx, nil)
if s.userClient == nil {
return SetUserStatusResult{}, fmt.Errorf("user service client is not configured")
}
result, err := s.userClient.SetUserStatus(ctx, userclient.SetUserStatusRequest{
RequestID: strings.TrimSpace(requestID),
Caller: "hyapp-admin-server",
TargetUserID: userID,
Status: status,
OperatorType: "admin",
OperatorUserID: operatorUserID,
Reason: "admin_app_user_status",
})
if err != nil {
return AppUser{}, err
return SetUserStatusResult{}, err
}
defer rollbackIfOpen(tx)
now := nowMillis()
appCode := appctx.FromContext(ctx)
result, err := tx.ExecContext(ctx, `UPDATE users SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ?`, status, now, appCode, userID)
user, err := s.GetUser(ctx, userID)
if err != nil {
return AppUser{}, err
return SetUserStatusResult{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return AppUser{}, err
output := SetUserStatusResult{User: user}
if result != nil {
output.RevokedSessionCount = result.RevokedSessionCount
output.AccessTokenRevoked = result.AccessTokenRevoked
output.AccessTokenError = result.AccessTokenError
output.IMKicked = result.IMKicked
output.IMKickError = result.IMKickError
output.RoomEvicted = result.RoomEvicted
output.RoomID = result.RoomID
output.RTCKicked = result.RTCKicked
output.RTCKickError = result.RTCKickError
output.RoomEvictError = result.RoomEvictError
}
if affected == 0 {
return AppUser{}, ErrNotFound
}
if status == "banned" || status == "disabled" {
if _, err := tx.ExecContext(ctx, `
UPDATE auth_sessions
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = '', revoked_by = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL
`, now, "ADMIN_USER_STATUS", "admin", now, appCode, userID); err != nil {
return AppUser{}, err
}
}
if err := tx.Commit(); err != nil {
return AppUser{}, err
}
return s.GetUser(ctx, userID)
return output, nil
}
func (s *Service) SetPassword(ctx context.Context, userID int64, password string) error {

View File

@ -0,0 +1,23 @@
package coinledger
type coinLedgerUserDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type coinLedgerEntryDTO struct {
EntryID int64 `json:"entryId"`
TransactionID string `json:"transactionId"`
UserID string `json:"userId"`
User coinLedgerUserDTO `json:"user"`
BizType string `json:"bizType"`
Direction string `json:"direction"`
Amount int64 `json:"amount"`
AvailableDelta int64 `json:"availableDelta"`
AvailableAfter int64 `json:"availableAfter"`
CounterpartyUserID string `json:"counterpartyUserId"`
RoomID string `json:"roomId"`
CreatedAtMS int64 `json:"createdAtMs"`
}

View File

@ -0,0 +1,80 @@
package coinledger
import (
"database/sql"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(userDB *sql.DB, walletDB *sql.DB) *Handler {
return &Handler{service: NewService(userDB, walletDB)}
}
func (h *Handler) ListCoinLedger(c *gin.Context) {
query, ok := parseListQuery(c)
if !ok {
return
}
items, total, err := h.service.ListCoinLedger(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
if err != nil {
response.ServerError(c, "获取金币流水失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return listQuery{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return listQuery{}, false
}
if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS {
response.BadRequest(c, "时间区间不正确")
return listQuery{}, false
}
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
UserKeyword: strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user_id", "userId", "keyword")),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}), true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0, false
}
return parsed, true
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,9 @@
package coinledger
type listQuery struct {
Page int
PageSize int
UserKeyword string
StartAtMS int64
EndAtMS int64
}

View File

@ -0,0 +1,15 @@
package coinledger
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/operations/coin-ledger", middleware.RequirePermission("coin-ledger:view"), h.ListCoinLedger)
}

View File

@ -0,0 +1,294 @@
package coinledger
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
)
const (
coinAssetType = "COIN"
directionIn = "income"
directionOut = "expense"
)
type Service struct {
userDB *sql.DB
walletDB *sql.DB
}
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
func NewService(userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userDB: userDB, walletDB: walletDB}
}
func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query listQuery) ([]coinLedgerEntryDTO, int64, error) {
query = normalizeListQuery(query)
if s == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
}
userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword)
if err != nil {
return nil, 0, err
}
if userFiltered && len(userIDs) == 0 {
return []coinLedgerEntryDTO{}, 0, nil
}
// 后台只做只读查询:金币流水事实仍以 wallet_entries 追加分录为准,不在 admin 库冗余账务状态。
whereSQL, args := coinLedgerWhere(appCode, query, userIDs)
var total int64
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.walletDB.QueryContext(ctx, `
SELECT e.entry_id, e.transaction_id, e.user_id, wt.biz_type,
e.available_delta, e.available_after, e.counterparty_user_id,
e.room_id, e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL+`
ORDER BY e.created_at_ms DESC, e.entry_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]coinLedgerEntryDTO, 0, query.PageSize)
itemUserIDs := make([]int64, 0, query.PageSize)
for rows.Next() {
var item coinLedgerEntryDTO
var userID int64
var counterpartyUserID int64
if err := rows.Scan(
&item.EntryID,
&item.TransactionID,
&userID,
&item.BizType,
&item.AvailableDelta,
&item.AvailableAfter,
&counterpartyUserID,
&item.RoomID,
&item.CreatedAtMS,
); err != nil {
return nil, 0, err
}
item.UserID = strconv.FormatInt(userID, 10)
item.User = coinLedgerUserDTO{UserID: item.UserID}
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
item.Direction = directionForDelta(item.AvailableDelta)
item.Amount = absInt64(item.AvailableDelta)
items = append(items, item)
itemUserIDs = append(itemUserIDs, userID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
profiles, err := s.userProfiles(ctx, appCode, itemUserIDs)
if err != nil {
return nil, 0, err
}
for i := range items {
userID, _ := strconv.ParseInt(items[i].UserID, 10, 64)
if profile, ok := profiles[userID]; ok {
items[i].User = coinLedgerUserDTO{
UserID: strconv.FormatInt(profile.UserID, 10),
DisplayUserID: profile.DisplayUserID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
}
return items, total, nil
}
func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword string) ([]int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil, false, nil
}
directUserIDs := make([]int64, 0, 1)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
directUserIDs = append(directUserIDs, numeric)
}
if s == nil || s.userDB == nil {
if len(directUserIDs) > 0 {
return directUserIDs, true, nil
}
return nil, true, fmt.Errorf("user mysql is not configured")
}
// 用户列筛选同时支持长 ID、当前短 ID 和昵称;数字关键字直接加入 user_id避免用户资料缺失时查不到账务事实。
args := []any{appCode}
where := "WHERE app_code = ? AND (current_display_user_id = ?"
args = append(args, keyword)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
where += " OR user_id = ?"
args = append(args, numeric)
}
where += " OR username LIKE ? ESCAPE '\\\\')"
args = append(args, "%"+escapeLike(keyword)+"%")
rows, err := s.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
`+where+`
ORDER BY updated_at_ms DESC, user_id DESC
LIMIT 1000`,
args...,
)
if err != nil {
return nil, true, err
}
defer rows.Close()
userIDs := append([]int64(nil), directUserIDs...)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, true, err
}
userIDs = append(userIDs, userID)
}
return uniqueInt64s(userIDs), true, rows.Err()
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
result := make(map[int64]userProfile, len(userIDs))
if s == nil || s.userDB == nil || len(userIDs) == 0 {
return result, nil
}
userIDs = uniqueInt64s(userIDs)
args := make([]any, 0, len(userIDs)+1)
args = append(args, appCode)
for _, id := range userIDs {
args = append(args, id)
}
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (%s)
`, placeholders(len(userIDs))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var profile userProfile
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ?"
args := []any{appCode, coinAssetType}
if query.StartAtMS > 0 {
where += " AND e.created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND e.created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if len(userIDs) > 0 {
where += fmt.Sprintf(" AND e.user_id IN (%s)", placeholders(len(userIDs)))
for _, id := range userIDs {
args = append(args, id)
}
}
return where, args
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.UserKeyword = strings.TrimSpace(query.UserKeyword)
return query
}
func directionForDelta(delta int64) string {
if delta < 0 {
return directionOut
}
return directionIn
}
func absInt64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
func formatOptionalID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func offset(page int, pageSize int) int {
if page < 1 {
page = 1
}
return (page - 1) * pageSize
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func uniqueInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
value = strings.ReplaceAll(value, `_`, `\_`)
return value
}

View File

@ -0,0 +1,30 @@
package coinledger
import "testing"
func TestNormalizeListQueryCapsPageSize(t *testing.T) {
query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, UserKeyword: " 10001 "})
if query.Page != 1 || query.PageSize != 100 || query.UserKeyword != "10001" {
t.Fatalf("normalized query mismatch: %+v", query)
}
}
func TestCoinLedgerWhereUsesTimeWindowAndUserFilter(t *testing.T) {
query := listQuery{StartAtMS: 100, EndAtMS: 200}
where, args := coinLedgerWhere("lalu", query, []int64{11, 22})
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.user_id IN (?,?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 6 || args[0] != "lalu" || args[1] != coinAssetType || args[2] != int64(100) || args[3] != int64(200) || args[4] != int64(11) || args[5] != int64(22) {
t.Fatalf("args mismatch: %#v", args)
}
}
func TestDirectionAndAmountForDelta(t *testing.T) {
if directionForDelta(-9) != directionOut || absInt64(-9) != 9 {
t.Fatalf("expense projection mismatch")
}
if directionForDelta(12) != directionIn || absInt64(12) != 12 {
t.Fatalf("income projection mismatch")
}
}

View File

@ -1,6 +1,12 @@
package payment
import walletv1 "hyapp.local/api/proto/wallet/v1"
import (
"fmt"
"strconv"
"strings"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
type rechargeBillDTO struct {
AppCode string `json:"appCode"`
@ -23,6 +29,32 @@ type rechargeBillDTO struct {
CreatedAtMS int64 `json:"createdAtMs"`
}
type rechargeProductDTO struct {
AppCode string `json:"appCode"`
ProductID int64 `json:"productId"`
ProductCode string `json:"productCode"`
ProductName string `json:"productName"`
Description string `json:"description"`
Platform string `json:"platform"`
Channel string `json:"channel"`
CurrencyCode string `json:"currencyCode"`
AmountUSDT string `json:"amountUsdt"`
AmountUSDTMicro int64 `json:"amountUsdtMicro"`
AmountMicro int64 `json:"amountMicro"`
AmountMinor int64 `json:"amountMinor"`
CoinAmount int64 `json:"coinAmount"`
PolicyVersion string `json:"policyVersion"`
RegionIDs []int64 `json:"regionIds"`
ResourceAssetType string `json:"resourceAssetType"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
CreatedByUserID int64 `json:"createdByUserId"`
UpdatedByUserID int64 `json:"updatedByUserId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
if item == nil {
return rechargeBillDTO{}
@ -48,3 +80,46 @@ func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
CreatedAtMS: item.GetCreatedAtMs(),
}
}
func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO {
if item == nil {
return rechargeProductDTO{}
}
return rechargeProductDTO{
AppCode: item.GetAppCode(),
ProductID: item.GetProductId(),
ProductCode: item.GetProductCode(),
ProductName: item.GetProductName(),
Description: item.GetDescription(),
Platform: item.GetPlatform(),
Channel: item.GetChannel(),
CurrencyCode: item.GetCurrencyCode(),
AmountUSDT: formatUSDTMicro(item.GetAmountMicro()),
AmountUSDTMicro: item.GetAmountMicro(),
AmountMicro: item.GetAmountMicro(),
AmountMinor: item.GetAmountMinor(),
CoinAmount: item.GetCoinAmount(),
PolicyVersion: item.GetPolicyVersion(),
RegionIDs: append([]int64(nil), item.GetRegionIds()...),
ResourceAssetType: item.GetResourceAssetType(),
Status: item.GetStatus(),
Enabled: item.GetEnabled(),
SortOrder: item.GetSortOrder(),
CreatedByUserID: item.GetCreatedByUserId(),
UpdatedByUserID: item.GetUpdatedByUserId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func formatUSDTMicro(amount int64) string {
if amount <= 0 {
return "0"
}
whole := amount / usdtMicroUnit
fraction := amount % usdtMicroUnit
if fraction == 0 {
return strconv.FormatInt(whole, 10)
}
return fmt.Sprintf("%d.%s", whole, strings.TrimRight(fmt.Sprintf("%06d", fraction), "0"))
}

View File

@ -1,6 +1,8 @@
package payment
import (
"errors"
"math"
"strconv"
"strings"
@ -12,14 +14,19 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const usdtMicroUnit int64 = 1_000_000
type Handler struct {
wallet walletclient.Client
audit shared.OperationLogger
}
func New(wallet walletclient.Client) *Handler {
return &Handler{wallet: wallet}
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, audit: audit}
}
func (h *Handler) ListRechargeBills(c *gin.Context) {
@ -50,6 +57,128 @@ func (h *Handler) ListRechargeBills(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ListRechargeProducts(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.wallet.ListAdminRechargeProducts(c.Request.Context(), &walletv1.ListAdminRechargeProductsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
writeWalletError(c, err, "获取内购配置失败")
return
}
items := make([]rechargeProductDTO, 0, len(resp.GetProducts()))
for _, item := range resp.GetProducts() {
items = append(items, rechargeProductFromProto(item))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) CreateRechargeProduct(c *gin.Context) {
var request rechargeProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "内购配置参数不正确")
return
}
amountMicro, err := parseUSDTMicro(request.AmountUSDT, request.AmountUSDTMicro)
if err != nil {
response.BadRequest(c, "金额USDT不正确")
return
}
resp, err := h.wallet.CreateRechargeProduct(c.Request.Context(), &walletv1.CreateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AmountMicro: amountMicro,
CoinAmount: request.CoinAmount,
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "创建内购配置失败")
return
}
item := rechargeProductFromProto(resp.GetProduct())
shared.OperationLogWithResourceID(c, h.audit, "create-recharge-product", "wallet_recharge_products", strconv.FormatInt(item.ProductID, 10), "success", item.ProductCode)
response.Created(c, item)
}
func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
productID, ok := productID(c)
if !ok {
return
}
var request rechargeProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "内购配置参数不正确")
return
}
amountMicro, err := parseUSDTMicro(request.AmountUSDT, request.AmountUSDTMicro)
if err != nil {
response.BadRequest(c, "金额USDT不正确")
return
}
resp, err := h.wallet.UpdateRechargeProduct(c.Request.Context(), &walletv1.UpdateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID,
AmountMicro: amountMicro,
CoinAmount: request.CoinAmount,
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改内购配置失败")
return
}
item := rechargeProductFromProto(resp.GetProduct())
shared.OperationLogWithResourceID(c, h.audit, "update-recharge-product", "wallet_recharge_products", strconv.FormatInt(item.ProductID, 10), "success", item.ProductCode)
response.OK(c, item)
}
func (h *Handler) DeleteRechargeProduct(c *gin.Context) {
productID, ok := productID(c)
if !ok {
return
}
_, err := h.wallet.DeleteRechargeProduct(c.Request.Context(), &walletv1.DeleteRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "删除内购配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "delete-recharge-product", "wallet_recharge_products", strconv.FormatInt(productID, 10), "success", "")
response.OK(c, gin.H{"deleted": true})
}
type rechargeProductRequest struct {
AmountUSDT string `json:"amountUsdt"`
AmountUSDTMicro int64 `json:"amountUsdtMicro"`
CoinAmount int64 `json:"coinAmount"`
ProductName string `json:"productName"`
Description string `json:"description"`
Platform string `json:"platform"`
RegionIDs []int64 `json:"regionIds"`
Enabled bool `json:"enabled"`
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
@ -70,3 +199,84 @@ func firstQuery(c *gin.Context, keys ...string) string {
}
return ""
}
func productID(c *gin.Context) (int64, bool) {
value := strings.TrimSpace(c.Param("product_id"))
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
response.BadRequest(c, "内购配置 ID 不正确")
return 0, false
}
return parsed, true
}
func actorID(c *gin.Context) int64 {
return int64(shared.ActorFromContext(c).UserID)
}
func parseUSDTMicro(amountText string, amountMicro int64) (int64, error) {
if amountMicro > 0 {
return amountMicro, nil
}
raw := strings.TrimSpace(amountText)
if raw == "" {
return 0, errors.New("amount is required")
}
if strings.HasPrefix(raw, "+") {
raw = strings.TrimPrefix(raw, "+")
}
parts := strings.Split(raw, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, errors.New("amount format is invalid")
}
if !allDigits(parts[0]) {
return 0, errors.New("amount integer part is invalid")
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || whole > math.MaxInt64/usdtMicroUnit {
return 0, errors.New("amount is too large")
}
var fraction int64
if len(parts) == 2 {
if parts[1] == "" || len(parts[1]) > 6 || !allDigits(parts[1]) {
return 0, errors.New("amount fraction part is invalid")
}
padded := parts[1] + strings.Repeat("0", 6-len(parts[1]))
fraction, err = strconv.ParseInt(padded, 10, 64)
if err != nil {
return 0, errors.New("amount fraction part is invalid")
}
}
if whole > (math.MaxInt64-fraction)/usdtMicroUnit {
return 0, errors.New("amount is too large")
}
value := whole*usdtMicroUnit + fraction
if value <= 0 {
return 0, errors.New("amount must be positive")
}
return value, nil
}
func allDigits(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return value != ""
}
func writeWalletError(c *gin.Context, err error, fallback string) {
message := fallback
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {
message = st.Message()
}
switch status.Code(err) {
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
response.BadRequest(c, message)
case codes.NotFound:
response.NotFound(c, message)
default:
response.ServerError(c, fallback)
}
}

View File

@ -12,4 +12,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)
protected.DELETE("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:delete"), h.DeleteRechargeProduct)
}

View File

@ -0,0 +1,305 @@
package registrationreward
import (
"context"
"database/sql"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
activity activityclient.Client
userDB *sql.DB
audit shared.OperationLogger
}
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{activity: activity, userDB: userDB, audit: audit}
}
type configRequest struct {
Enabled bool `json:"enabled"`
RewardType string `json:"reward_type"`
CoinAmount int64 `json:"coin_amount"`
ResourceGroupID int64 `json:"resource_group_id"`
DailyLimit int64 `json:"daily_limit"`
}
type configDTO struct {
AppCode string `json:"app_code"`
Enabled bool `json:"enabled"`
RewardType string `json:"reward_type"`
CoinAmount int64 `json:"coin_amount"`
ResourceGroupID int64 `json:"resource_group_id"`
DailyLimit int64 `json:"daily_limit"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type claimDTO struct {
ClaimID string `json:"claim_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
User *userDTO `json:"user,omitempty"`
RewardDay string `json:"reward_day"`
RewardType string `json:"reward_type"`
CoinAmount int64 `json:"coin_amount"`
ResourceGroupID int64 `json:"resource_group_id"`
Status string `json:"status"`
WalletCommandID string `json:"wallet_command_id"`
WalletTransactionID string `json:"wallet_transaction_id"`
ResourceGrantID string `json:"resource_grant_id"`
FailureReason string `json:"failure_reason"`
ClaimedAtMS int64 `json:"claimed_at_ms"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type userDTO struct {
UserID int64 `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
func (h *Handler) GetRegistrationRewardConfig(c *gin.Context) {
resp, err := h.activity.GetRegistrationRewardConfig(c.Request.Context(), &activityv1.GetRegistrationRewardConfigRequest{Meta: h.meta(c)})
if err != nil {
response.ServerError(c, "获取注册奖励配置失败")
return
}
response.OK(c, configFromProto(resp.GetConfig()))
}
func (h *Handler) UpdateRegistrationRewardConfig(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "注册奖励配置参数不正确")
return
}
resp, err := h.activity.UpdateRegistrationRewardConfig(c.Request.Context(), &activityv1.UpdateRegistrationRewardConfigRequest{
Meta: h.meta(c),
Enabled: req.Enabled,
RewardType: strings.TrimSpace(req.RewardType),
CoinAmount: req.CoinAmount,
ResourceGroupId: req.ResourceGroupID,
DailyLimit: req.DailyLimit,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := configFromProto(resp.GetConfig())
shared.OperationLogWithResourceID(c, h.audit, "update-registration-reward", "registration_reward_configs", item.AppCode, "success", item.RewardType)
response.OK(c, item)
}
func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) {
options := shared.ListOptions(c)
userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
if !ok {
response.ServerError(c, "查询用户信息失败")
return
}
if options.Keyword != "" && !matched {
response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
resp, err := h.activity.ListRegistrationRewardClaims(c.Request.Context(), &activityv1.ListRegistrationRewardClaimsRequest{
Meta: h.meta(c),
Status: strings.TrimSpace(options.Status),
UserId: userID,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取注册奖励领取记录失败")
return
}
items := make([]claimDTO, 0, len(resp.GetClaims()))
for _, item := range resp.GetClaims() {
items = append(items, claimFromProto(item))
}
if err := h.fillClaimUsers(c.Request.Context(), items); err != nil {
response.ServerError(c, "补全用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "admin-server",
AppCode: appctx.FromContext(c.Request.Context()),
SentAtMs: time.Now().UnixMilli(),
}
}
func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) {
if keyword == "" {
return 0, false, true
}
if h.userDB == nil {
return 0, false, true
}
appCode := appctx.FromContext(ctx)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
)
if err != nil {
return 0, false, false
}
defer rows.Close()
if !rows.Next() {
return 0, false, rows.Err() == nil
}
var userID int64
if err := rows.Scan(&userID); err != nil {
return 0, false, false
}
return userID, true, rows.Err() == nil
}
func (h *Handler) fillClaimUsers(ctx context.Context, claims []claimDTO) error {
if h.userDB == nil || len(claims) == 0 {
return nil
}
ids := collectClaimUserIDs(claims)
if len(ids) == 0 {
return nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
args...,
)
if err != nil {
return err
}
defer rows.Close()
users := make(map[int64]userDTO, len(ids))
for rows.Next() {
var user userDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
return err
}
users[user.UserID] = user
}
if err := rows.Err(); err != nil {
return err
}
for index := range claims {
if user, ok := users[claims[index].UserID]; ok {
claims[index].User = &user
continue
}
// 领取事实必须可追踪;用户资料缺失时仍返回 user_id 让后台能定位数据问题。
claims[index].User = &userDTO{UserID: claims[index].UserID}
}
return nil
}
func configFromProto(item *activityv1.RegistrationRewardConfig) configDTO {
if item == nil {
return configDTO{RewardType: "coin"}
}
return configDTO{
AppCode: item.GetAppCode(),
Enabled: item.GetEnabled(),
RewardType: item.GetRewardType(),
CoinAmount: item.GetCoinAmount(),
ResourceGroupID: item.GetResourceGroupId(),
DailyLimit: item.GetDailyLimit(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func claimFromProto(item *activityv1.RegistrationRewardClaim) claimDTO {
if item == nil {
return claimDTO{}
}
return claimDTO{
ClaimID: item.GetClaimId(),
CommandID: item.GetCommandId(),
UserID: item.GetUserId(),
RewardDay: item.GetRewardDay(),
RewardType: item.GetRewardType(),
CoinAmount: item.GetCoinAmount(),
ResourceGroupID: item.GetResourceGroupId(),
Status: item.GetStatus(),
WalletCommandID: item.GetWalletCommandId(),
WalletTransactionID: item.GetWalletTransactionId(),
ResourceGrantID: item.GetResourceGrantId(),
FailureReason: item.GetFailureReason(),
ClaimedAtMS: item.GetClaimedAtMs(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func collectClaimUserIDs(claims []claimDTO) []int64 {
seen := make(map[int64]struct{}, len(claims))
ids := make([]int64, 0, len(claims))
for _, claim := range claims {
if claim.UserID <= 0 {
continue
}
if _, ok := seen[claim.UserID]; ok {
continue
}
seen[claim.UserID] = struct{}{}
ids = append(ids, claim.UserID)
}
return ids
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
items := make([]string, count)
for index := range items {
items[index] = "?"
}
return strings.Join(items, ",")
}
func int64Args(values []int64) []any {
args := make([]any, 0, len(values))
for _, value := range values {
args = append(args, value)
}
return args
}

View File

@ -0,0 +1,17 @@
package registrationreward
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/registration-reward/config", middleware.RequirePermission("registration-reward:view"), h.GetRegistrationRewardConfig)
protected.PUT("/admin/activity/registration-reward/config", middleware.RequirePermission("registration-reward:update"), h.UpdateRegistrationRewardConfig)
protected.GET("/admin/activity/registration-reward/claims", middleware.RequirePermission("registration-reward:view"), h.ListRegistrationRewardClaims)
}

View File

@ -104,11 +104,21 @@ type grantDTO struct {
Status string `json:"status"`
Reason string `json:"reason"`
OperatorUserID int64 `json:"operatorUserId"`
Operator *operatorDTO `json:"operator,omitempty"`
Items []grantItemDTO `json:"items"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type operatorDTO struct {
Source string `json:"source"`
UserID int64 `json:"userId,string,omitempty"`
DisplayUserID string `json:"displayUserId,omitempty"`
Username string `json:"username,omitempty"`
Name string `json:"name,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
func resourceFromProto(item *walletv1.Resource) resourceDTO {
if item == nil {
return resourceDTO{}

View File

@ -1,6 +1,7 @@
package resource
import (
"database/sql"
"fmt"
"strconv"
"strings"
@ -9,6 +10,7 @@ import (
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -17,11 +19,13 @@ import (
type Handler struct {
wallet walletclient.Client
store *repository.Store
userDB *sql.DB
audit shared.OperationLogger
}
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, audit: audit}
func New(wallet walletclient.Client, store *repository.Store, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, store: store, userDB: userDB, audit: audit}
}
func (h *Handler) ListResources(c *gin.Context) {
@ -334,6 +338,10 @@ func (h *Handler) ListResourceGrants(c *gin.Context) {
for _, item := range resp.GetGrants() {
items = append(items, grantFromProto(item))
}
if err := h.enrichGrantOperators(c.Request.Context(), items); err != nil {
response.ServerError(c, "获取资源赠送操作人失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

View File

@ -0,0 +1,172 @@
package resource
import (
"context"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
)
const (
grantSourceAdmin = "admin"
grantSourceManagerCenter = "manager_center"
)
type adminOperator struct {
UserID uint
Username string
Name string
}
type managerOperator struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
func (h *Handler) enrichGrantOperators(ctx context.Context, grants []grantDTO) error {
// operator_user_id 在 wallet 发放事实里只保存稳定 ID后台列表按来源补展示资料避免把 admin 用户和 app 用户混成同一张表。
adminIDs, managerIDs := collectGrantOperatorIDs(grants)
admins, err := h.queryAdminOperators(adminIDs)
if err != nil {
return err
}
managers, err := h.queryManagerOperators(ctx, managerIDs)
if err != nil {
return err
}
applyGrantOperators(grants, admins, managers)
return nil
}
func (h *Handler) queryAdminOperators(ids []uint) (map[uint]adminOperator, error) {
out := make(map[uint]adminOperator, len(ids))
if len(ids) == 0 || h.store == nil {
return out, nil
}
users, err := h.store.UsersByIDs(ids)
if err != nil {
return nil, err
}
for id, user := range users {
out[id] = adminOperatorFromModel(user)
}
return out, nil
}
func (h *Handler) queryManagerOperators(ctx context.Context, ids []int64) (map[int64]managerOperator, error) {
out := make(map[int64]managerOperator, len(ids))
if len(ids) == 0 || h.userDB == nil {
return out, nil
}
// 经理中心发放人是 app 用户,只读 user-service 的用户库投影;资源发放事实仍然以 wallet-service 返回为准。
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id,
current_display_user_id,
COALESCE(username, ''),
COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var operator managerOperator
if err := rows.Scan(&operator.UserID, &operator.DisplayUserID, &operator.Username, &operator.Avatar); err != nil {
return nil, err
}
out[operator.UserID] = operator
}
return out, rows.Err()
}
func applyGrantOperators(grants []grantDTO, admins map[uint]adminOperator, managers map[int64]managerOperator) {
for index := range grants {
source := normalizeGrantSource(grants[index].GrantSource)
operatorID := grants[index].OperatorUserID
if operatorID <= 0 {
continue
}
// 即使资料缺失也返回 source + user_id前端仍能展示可追溯的操作人线索。
operator := &operatorDTO{
Source: source,
UserID: operatorID,
}
switch source {
case grantSourceAdmin:
if admin, ok := admins[uint(operatorID)]; ok {
operator.Username = admin.Username
operator.Name = admin.Name
}
case grantSourceManagerCenter:
if manager, ok := managers[operatorID]; ok {
operator.DisplayUserID = manager.DisplayUserID
operator.Username = manager.Username
operator.Avatar = manager.Avatar
}
}
grants[index].Operator = operator
}
}
func collectGrantOperatorIDs(grants []grantDTO) ([]uint, []int64) {
adminSet := make(map[uint]struct{})
managerSet := make(map[int64]struct{})
for _, grant := range grants {
if grant.OperatorUserID <= 0 {
continue
}
switch normalizeGrantSource(grant.GrantSource) {
case grantSourceAdmin:
adminSet[uint(grant.OperatorUserID)] = struct{}{}
case grantSourceManagerCenter:
managerSet[grant.OperatorUserID] = struct{}{}
}
}
adminIDs := make([]uint, 0, len(adminSet))
for id := range adminSet {
adminIDs = append(adminIDs, id)
}
managerIDs := make([]int64, 0, len(managerSet))
for id := range managerSet {
managerIDs = append(managerIDs, id)
}
return adminIDs, managerIDs
}
func adminOperatorFromModel(user model.User) adminOperator {
return adminOperator{
UserID: user.ID,
Username: user.Username,
Name: user.Name,
}
}
func normalizeGrantSource(source string) string {
return strings.ToLower(strings.TrimSpace(source))
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
items := make([]string, count)
for index := range items {
items[index] = "?"
}
return strings.Join(items, ",")
}
func int64Args(values []int64) []any {
args := make([]any, 0, len(values))
for _, value := range values {
args = append(args, value)
}
return args
}

View File

@ -0,0 +1,23 @@
package resource
import "testing"
func TestApplyGrantOperatorsUsesSourceSpecificProfiles(t *testing.T) {
grants := []grantDTO{
{GrantID: "grant-admin", GrantSource: "admin", OperatorUserID: 7},
{GrantID: "grant-manager", GrantSource: "manager_center", OperatorUserID: 9001},
}
applyGrantOperators(grants, map[uint]adminOperator{
7: {UserID: 7, Username: "admin", Name: "后台管理员"},
}, map[int64]managerOperator{
9001: {UserID: 9001, DisplayUserID: "10086", Username: "经理 A", Avatar: "https://cdn.example/avatar.png"},
})
if grants[0].Operator == nil || grants[0].Operator.Source != "admin" || grants[0].Operator.Name != "后台管理员" || grants[0].Operator.Username != "admin" {
t.Fatalf("admin operator mismatch: %+v", grants[0].Operator)
}
if grants[1].Operator == nil || grants[1].Operator.Source != "manager_center" || grants[1].Operator.DisplayUserID != "10086" || grants[1].Operator.Avatar == "" || grants[1].Operator.Username != "经理 A" {
t.Fatalf("manager operator mismatch: %+v", grants[1].Operator)
}
}

View File

@ -256,7 +256,7 @@ func resourceGrantStrategy(resourceType string) string {
switch resourceType {
case "coin":
return "wallet_credit"
case "avatar_frame", "vehicle", "chat_bubble":
case "avatar_frame", "profile_card", "vehicle", "chat_bubble":
return "extend_expiry"
case "badge":
return "set_active_flag"

View File

@ -93,10 +93,12 @@ func (h *Handler) DeleteRoom(c *gin.Context) {
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
query := listQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
Status: options.Status,
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
Status: options.Status,
SortBy: firstQueryValue(c, "sortBy", "sort_by"),
SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"),
}
regionID, ok := parseOptionalInt64Query(c, "regionId", "region_id")
if !ok {
@ -106,6 +108,15 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
return normalizeListQuery(query), true
}
func firstQueryValue(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {
return value
}
}
return ""
}
func parseOptionalInt64Query(c *gin.Context, primary string, fallback string) (int64, bool) {
raw := strings.TrimSpace(c.Query(primary))
if raw == "" {

View File

@ -1,11 +1,13 @@
package roomadmin
type listQuery struct {
Page int
PageSize int
Keyword string
Status string
RegionID int64
Page int
PageSize int
Keyword string
Status string
RegionID int64
SortBy string
SortDirection string
}
type updateRoomRequest struct {

View File

@ -35,7 +35,6 @@ type Room struct {
CoverURL string `json:"coverUrl"`
CreatedAtMs int64 `json:"createdAtMs"`
Heat int64 `json:"heat"`
HostUserID string `json:"hostUserId"`
Mode string `json:"mode"`
OccupiedSeatCount int32 `json:"occupiedSeatCount"`
OnlineCount int32 `json:"onlineCount"`
@ -43,6 +42,7 @@ type Room struct {
OwnerUserID string `json:"ownerUserId"`
RegionName string `json:"regionName"`
RoomID string `json:"roomId"`
RoomContribution int64 `json:"roomContribution"`
SeatCount int32 `json:"seatCount"`
SortScore int64 `json:"sortScore"`
Status string `json:"status"`
@ -76,8 +76,8 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
whereSQL += " AND (rle.room_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ? OR CAST(rle.host_user_id AS CHAR) LIKE ?)"
args = append(args, like, like, like, like)
whereSQL += " AND (rle.room_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)"
args = append(args, like, like, like)
}
total, err := countRows(ctx, s.db, whereSQL, args...)
if err != nil {
@ -87,7 +87,6 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
SELECT rle.room_id,
rle.visible_region_id,
rle.owner_user_id,
rle.host_user_id,
rle.title,
rle.cover_url,
rle.mode,
@ -100,9 +99,9 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
rle.created_at_ms,
rle.updated_at_ms
%s
ORDER BY rle.created_at_ms DESC, rle.room_id DESC
ORDER BY %s
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
`, whereSQL, roomListOrderBy(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
@ -134,7 +133,6 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
SELECT room_id,
visible_region_id,
owner_user_id,
host_user_id,
title,
cover_url,
mode,
@ -389,12 +387,10 @@ type roomScanner interface {
func scanRoom(row roomScanner) (Room, error) {
var item Room
var ownerUserID int64
var hostUserID int64
err := row.Scan(
&item.RoomID,
&item.VisibleRegionID,
&ownerUserID,
&hostUserID,
&item.Title,
&item.CoverURL,
&item.Mode,
@ -408,8 +404,8 @@ func scanRoom(row roomScanner) (Room, error) {
&item.UpdatedAtMs,
)
item.OwnerUserID = strconv.FormatInt(ownerUserID, 10)
item.HostUserID = strconv.FormatInt(hostUserID, 10)
item.Owner = RoomOwner{UserID: item.OwnerUserID}
item.RoomContribution = item.Heat
if item.Status != "active" {
item.Status = "closed"
}
@ -431,9 +427,42 @@ func normalizeListQuery(query listQuery) listQuery {
if status, ok := normalizeRoomStatus(query.Status); ok {
query.Status = status
}
query.SortBy = normalizeRoomListSortBy(query.SortBy)
query.SortDirection = normalizeRoomListSortDirection(query.SortDirection)
return query
}
func roomListOrderBy(query listQuery) string {
// ORDER BY 字段只能来自 normalizeRoomListSortBy 的白名单,避免把 HTTP 查询值拼进 SQL。
if query.SortBy == "room_contribution" {
if query.SortDirection == "asc" {
return "rle.heat ASC, rle.created_at_ms DESC, rle.room_id DESC"
}
return "rle.heat DESC, rle.created_at_ms DESC, rle.room_id DESC"
}
return "rle.created_at_ms DESC, rle.room_id DESC"
}
func normalizeRoomListSortBy(sortBy string) string {
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "", "created_at", "createdat":
return ""
case "room_contribution", "roomcontribution", "contribution", "heat":
return "room_contribution"
default:
return ""
}
}
func normalizeRoomListSortDirection(direction string) string {
switch strings.ToLower(strings.TrimSpace(direction)) {
case "asc", "ascending":
return "asc"
default:
return "desc"
}
}
func normalizeRoomStatus(status string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(status)) {
case "":

View File

@ -0,0 +1,41 @@
package roomadmin
import "testing"
func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) {
tests := []struct {
name string
query listQuery
wantOrder string
}{
{
name: "default",
query: listQuery{},
wantOrder: "rle.created_at_ms DESC, rle.room_id DESC",
},
{
name: "contribution_desc",
query: listQuery{SortBy: "room_contribution", SortDirection: "desc"},
wantOrder: "rle.heat DESC, rle.created_at_ms DESC, rle.room_id DESC",
},
{
name: "contribution_asc",
query: listQuery{SortBy: "heat", SortDirection: "asc"},
wantOrder: "rle.heat ASC, rle.created_at_ms DESC, rle.room_id DESC",
},
{
name: "unknown_falls_back",
query: listQuery{SortBy: "owner_user_id", SortDirection: "asc"},
wantOrder: "rle.created_at_ms DESC, rle.room_id DESC",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
query := normalizeListQuery(test.query)
if got := roomListOrderBy(query); got != test.wantOrder {
t.Fatalf("order by mismatch: got %q want %q", got, test.wantOrder)
}
})
}
}

View File

@ -13,6 +13,21 @@ func (s *Store) ListUsers(options ListOptions) ([]model.User, int64, error) {
return s.ListUsersWithAccess(options, DataAccess{All: true})
}
func (s *Store) UsersByIDs(ids []uint) (map[uint]model.User, error) {
usersByID := make(map[uint]model.User, len(ids))
if len(ids) == 0 {
return usersByID, nil
}
var users []model.User
if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
for _, user := range users {
usersByID[user.ID] = user
}
return usersByID, nil
}
func (s *Store) ListUsersWithAccess(options ListOptions, access DataAccess) ([]model.User, int64, error) {
page, pageSize := normalizePage(options.Page, options.PageSize)
query := s.db.Model(&model.User{}).Preload("Roles")

View File

@ -9,6 +9,12 @@ import (
"gorm.io/gorm/clause"
)
type AppVersionListOptions struct {
AppCode string
Platform string
Keyword string
}
func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) {
var items []model.AppConfig
err := s.db.Where("`group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error
@ -38,3 +44,36 @@ func (s *Store) UpsertAppConfigs(items []model.AppConfig) error {
DoUpdates: clause.AssignmentColumns([]string{"value", "description", "updated_at_ms"}),
}).Create(&items).Error
}
func (s *Store) ListAppVersions(options AppVersionListOptions) ([]model.AppVersion, error) {
db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
if platform := strings.TrimSpace(options.Platform); platform != "" {
db = db.Where("platform = ?", platform)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
db = db.Where("version LIKE ? OR download_url LIKE ? OR description LIKE ?", like, like, like)
}
var items []model.AppVersion
err := db.Order("platform ASC, build_number DESC, id DESC").Find(&items).Error
return items, err
}
func (s *Store) GetAppVersion(appCode string, id uint) (model.AppVersion, error) {
var item model.AppVersion
err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error
return item, err
}
func (s *Store) CreateAppVersion(item *model.AppVersion) error {
return s.db.Create(item).Error
}
func (s *Store) UpdateAppVersion(item *model.AppVersion) error {
return s.db.Save(item).Error
}
func (s *Store) DeleteAppVersion(appCode string, id uint) error {
return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppVersion{}).Error
}

View File

@ -66,6 +66,7 @@ func (s *Store) AutoMigrate() error {
&model.Menu{},
&model.AppConfig{},
&model.AppBanner{},
&model.AppVersion{},
&model.RefreshToken{},
&model.LoginLog{},
&model.OperationLog{},

View File

@ -27,6 +27,10 @@ var defaultPermissions = []model.Permission{
{Name: "房间配置更新", Code: "room-config:update", Kind: "button"},
{Name: "APP 配置查看", Code: "app-config:view", Kind: "menu"},
{Name: "APP 配置更新", Code: "app-config:update", Kind: "button"},
{Name: "版本管理查看", Code: "app-version:view", Kind: "menu"},
{Name: "版本管理创建", Code: "app-version:create", Kind: "button"},
{Name: "版本管理更新", Code: "app-version:update", Kind: "button"},
{Name: "版本管理删除", Code: "app-version:delete", Kind: "button"},
{Name: "资源查看", Code: "resource:view", Kind: "menu"},
{Name: "资源创建", Code: "resource:create", Kind: "button"},
{Name: "资源更新", Code: "resource:update", Kind: "button"},
@ -58,7 +62,12 @@ var defaultPermissions = []model.Permission{
{Name: "币商创建", Code: "coin-seller:create", Kind: "button"},
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
{Name: "内购配置删除", Code: "payment-product:delete", Kind: "button"},
{Name: "游戏查看", Code: "game:view", Kind: "menu"},
{Name: "游戏创建", Code: "game:create", Kind: "button"},
{Name: "游戏更新", Code: "game:update", Kind: "button"},
@ -67,6 +76,8 @@ var defaultPermissions = []model.Permission{
{Name: "每日任务创建", Code: "daily-task:create", Kind: "button"},
{Name: "每日任务更新", Code: "daily-task:update", Kind: "button"},
{Name: "每日任务状态", Code: "daily-task:status", Kind: "button"},
{Name: "注册奖励查看", Code: "registration-reward:view", Kind: "menu"},
{Name: "注册奖励更新", Code: "registration-reward:update", Kind: "button"},
{Name: "角色查看", Code: "role:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"},
{Name: "角色更新", Code: "role:update", Kind: "button"},
@ -111,6 +122,7 @@ func (s *Store) seedMenus() error {
appUsersID := uint(0)
appConfigID := uint(0)
resourceID := uint(0)
operationsID := uint(0)
roomsID := uint(0)
paymentID := uint(0)
activityID := uint(0)
@ -124,10 +136,11 @@ func (s *Store) seedMenus() error {
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true},
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true},
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 68, Visible: true},
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 69, Visible: true},
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 70, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 71, Visible: true},
{Title: "运营管理", Code: "operations", Path: "", Icon: "operations", PermissionCode: "", Sort: 68, Visible: true},
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 69, Visible: true},
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 70, Visible: true},
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 71, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 72, Visible: true},
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
{Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true},
}
@ -154,6 +167,9 @@ func (s *Store) seedMenus() error {
if syncedMenu.Code == "resources" {
resourceID = syncedMenu.ID
}
if syncedMenu.Code == "operations" {
operationsID = syncedMenu.ID
}
if syncedMenu.Code == "rooms" {
roomsID = syncedMenu.ID
}
@ -175,25 +191,31 @@ func (s *Store) seedMenus() error {
{ParentID: &systemID, Title: "登录日志", Code: "logs-login", Path: "/logs/login", Icon: "login", PermissionCode: "log:view", Sort: 34, Visible: true},
{ParentID: &systemID, Title: "操作日志", Code: "logs-operations", Path: "/logs/operations", Icon: "receipt", PermissionCode: "log:view", Sort: 35, Visible: true},
{ParentID: &appUsersID, Title: "用户列表", Code: "app-user-list", Path: "/app/users", Icon: "users", PermissionCode: "app-user:view", Sort: 60, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &appUsersID, Title: "封禁列表", Code: "app-user-bans", Path: "/app/users/bans", Icon: "block", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 62, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 68, Visible: true},
{ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 69, Visible: true},
{ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true},
{ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 68, Visible: true},
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 69, Visible: true},
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
{ParentID: &hostOrgID, Title: "Agency 管理", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 80, Visible: true},
{ParentID: &hostOrgID, Title: "BD Leader 管理", Code: "host-org-bd-leaders", Path: "/host/bd-leaders", Icon: "shield", PermissionCode: "bd:view", Sort: 81, Visible: true},
{ParentID: &hostOrgID, Title: "BD 管理", Code: "host-org-bds", Path: "/host/bds", Icon: "team", PermissionCode: "bd:view", Sort: 82, Visible: true},
{ParentID: &hostOrgID, Title: "主播管理", Code: "host-org-hosts", Path: "/host/hosts", Icon: "users", PermissionCode: "host:view", Sort: 83, Visible: true},
{ParentID: &hostOrgID, Title: "币商管理", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 84, Visible: true},
{ParentID: &hostOrgID, Title: "经理列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "agency:view", Sort: 80, Visible: true},
{ParentID: &hostOrgID, Title: "Agency 列表", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 81, Visible: true},
{ParentID: &hostOrgID, Title: "BD Leader 列表", Code: "host-org-bd-leaders", Path: "/host/bd-leaders", Icon: "shield", PermissionCode: "bd:view", Sort: 82, Visible: true},
{ParentID: &hostOrgID, Title: "BD 列表", Code: "host-org-bds", Path: "/host/bds", Icon: "team", PermissionCode: "bd:view", Sort: 83, Visible: true},
{ParentID: &hostOrgID, Title: "主播列表", Code: "host-org-hosts", Path: "/host/hosts", Icon: "users", PermissionCode: "host:view", Sort: 84, Visible: true},
{ParentID: &hostOrgID, Title: "币商列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 85, Visible: true},
}
for _, menu := range children {
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
@ -421,6 +443,7 @@ func defaultRolePermissionCodes(code string) []string {
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create",
@ -431,7 +454,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"coin-ledger:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"game:view", "game:create", "game:update", "game:status",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"log:view",
@ -440,7 +463,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "game:view", "daily-task:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -449,6 +472,7 @@ func defaultRolePermissionCodes(code string) []string {
"room:view",
"room-config:view",
"app-config:view",
"app-version:view",
"resource:view",
"resource-group:view",
"resource-grant:view",
@ -459,7 +483,9 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view",
"bd:view",
"coin-seller:view",
"coin-ledger:view",
"payment-bill:view",
"payment-product:view",
"game:view",
"daily-task:view",
"role:view",
@ -481,6 +507,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create",
@ -489,12 +516,12 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"coin-ledger:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"game:view", "game:create", "game:update", "game:status",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
}
case "auditor", "readonly":
return []string{"room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "game:view", "daily-task:view"}
return []string{"room:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "coin-ledger:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view"}
default:
return nil
}

View File

@ -9,6 +9,7 @@ import (
"hyapp-admin-server/internal/modules/appuser"
"hyapp-admin-server/internal/modules/audit"
authroutes "hyapp-admin-server/internal/modules/auth"
"hyapp-admin-server/internal/modules/coinledger"
"hyapp-admin-server/internal/modules/countryregion"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
@ -20,6 +21,7 @@ import (
"hyapp-admin-server/internal/modules/notification"
"hyapp-admin-server/internal/modules/payment"
"hyapp-admin-server/internal/modules/rbac"
"hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/search"
@ -31,27 +33,29 @@ import (
)
type Handlers struct {
AdminUser *adminuser.Handler
AppConfig *appconfig.Handler
AppRegistry *appregistry.Handler
AppUser *appuser.Handler
Audit *audit.Handler
Auth *authroutes.Handler
CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
Game *gamemanagement.Handler
Health *health.Handler
HostOrg *hostorg.Handler
Job *job.Handler
Menu *menu.Handler
Notification *notification.Handler
Payment *payment.Handler
RBAC *rbac.Handler
Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler
Search *search.Handler
Upload *upload.Handler
AdminUser *adminuser.Handler
AppConfig *appconfig.Handler
AppRegistry *appregistry.Handler
AppUser *appuser.Handler
Audit *audit.Handler
Auth *authroutes.Handler
CoinLedger *coinledger.Handler
CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
Game *gamemanagement.Handler
Health *health.Handler
HostOrg *hostorg.Handler
Job *job.Handler
Menu *menu.Handler
Notification *notification.Handler
Payment *payment.Handler
RBAC *rbac.Handler
RegistrationReward *registrationreward.Handler
Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler
Search *search.Handler
Upload *upload.Handler
}
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
@ -66,6 +70,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit))
authroutes.RegisterRoutes(api, protected, h.Auth)
coinledger.RegisterRoutes(protected, h.CoinLedger)
menu.RegisterRoutes(protected, h.Menu)
rbac.RegisterRoutes(protected, h.RBAC)
resourcemodule.RegisterRoutes(protected, h.Resource)
@ -75,6 +80,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
appconfig.RegisterRoutes(protected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion)
dailytask.RegisterRoutes(protected, h.DailyTask)
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
gamemanagement.RegisterRoutes(protected, h.Game)
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
dashboard.RegisterRoutes(protected, h.Dashboard)

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS admin_app_versions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
platform VARCHAR(24) NOT NULL,
version VARCHAR(40) NOT NULL,
build_number BIGINT NOT NULL,
force_update BOOLEAN NOT NULL DEFAULT FALSE,
download_url VARCHAR(2048) NOT NULL,
description TEXT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
UNIQUE KEY uk_admin_app_versions_build (app_code, platform, build_number),
INDEX idx_admin_app_versions_latest (app_code, platform, build_number, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -155,6 +155,54 @@ CREATE TABLE IF NOT EXISTS task_reward_claims (
KEY idx_task_reward_claim_status (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS registration_reward_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
enabled TINYINT(1) NOT NULL DEFAULT 0,
reward_type VARCHAR(32) NOT NULL DEFAULT 'coin',
coin_amount BIGINT NOT NULL DEFAULT 0,
resource_group_id BIGINT NOT NULL DEFAULT 0,
daily_limit BIGINT NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS registration_reward_claims (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
claim_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
reward_day VARCHAR(32) NOT NULL,
reward_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL DEFAULT 0,
resource_group_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
wallet_command_id VARCHAR(128) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
resource_grant_id VARCHAR(96) NOT NULL DEFAULT '',
failure_reason VARCHAR(255) NOT NULL DEFAULT '',
claimed_at_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, claim_id),
UNIQUE KEY uk_registration_reward_command (app_code, command_id),
UNIQUE KEY uk_registration_reward_user_once (app_code, user_id),
KEY idx_registration_reward_list (app_code, created_at_ms, claim_id),
KEY idx_registration_reward_status (app_code, status, updated_at_ms),
KEY idx_registration_reward_day (app_code, reward_day, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS registration_reward_daily_counters (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
reward_day VARCHAR(32) NOT NULL,
reserved_count BIGINT NOT NULL DEFAULT 0,
granted_count BIGINT NOT NULL DEFAULT 0,
failed_count BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, reward_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS message_templates (
app_code VARCHAR(32) NOT NULL,
template_id VARCHAR(96) NOT NULL,

View File

@ -22,6 +22,7 @@ import (
activityservice "hyapp/services/activity-service/internal/service/activity"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
messageservice "hyapp/services/activity-service/internal/service/message"
registrationrewardservice "hyapp/services/activity-service/internal/service/registrationreward"
taskservice "hyapp/services/activity-service/internal/service/task"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
grpcserver "hyapp/services/activity-service/internal/transport/grpc"
@ -78,6 +79,7 @@ func New(cfg config.Config) (*App, error) {
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn)))
taskSvc := taskservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
registrationRewardSvc := registrationrewardservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
var broadcastPublisher broadcastservice.Publisher
if cfg.TencentIM.Enabled {
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -111,6 +113,8 @@ func New(cfg config.Config) (*App, error) {
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc))
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc))
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc))
activityv1.RegisterRegistrationRewardServiceServer(server, grpcserver.NewRegistrationRewardServer(registrationRewardSvc))
activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(registrationRewardSvc))
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)

View File

@ -0,0 +1,86 @@
package registrationreward
const (
RewardTypeCoin = "coin"
RewardTypeResourceGroup = "resource_group"
StatusPending = "pending"
StatusGranted = "granted"
StatusFailed = "failed"
ReasonEligible = "eligible"
ReasonNotConfigured = "not_configured"
ReasonDisabled = "disabled"
ReasonInvalidConfig = "invalid_config"
ReasonAlreadyClaimed = "already_claimed"
ReasonDailyLimitReached = "daily_limit_reached"
)
// Config 是注册奖励配置的 owner-side 快照daily_limit=0 表达 UTC 当天不限份数。
type Config struct {
AppCode string
Enabled bool
RewardType string
CoinAmount int64
ResourceGroupID int64
DailyLimit int64
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Claim 是注册奖励领取事实;每个 app_code + user_id 只允许一个有效事实。
type Claim struct {
ClaimID string
CommandID string
UserID int64
RewardDay string
RewardType string
CoinAmount int64
ResourceGroupID int64
Status string
WalletCommandID string
WalletTransactionID string
ResourceGrantID string
FailureReason string
ClaimedAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Eligibility 是 App 查询使用的稳定结果reason 让客户端能区分未配置、关闭、已领取和当日超额。
type Eligibility struct {
CanReceive bool
Reason string
Enabled bool
RewardType string
CoinAmount int64
ResourceGroupID int64
DailyLimit int64
DailyUsed int64
DailyRemaining int64
RewardDay string
ServerTimeMS int64
}
// IssueCommand 是 user-service 注册完成后传入的幂等命令。
type IssueCommand struct {
UserID int64
CommandID string
}
// PrepareResult 表达 activity 事务是否已经成功预留每日份数并创建/复用 pending claim。
type PrepareResult struct {
Claim Claim
Prepared bool
AlreadyGranted bool
Reason string
}
// ClaimQuery 是后台领取记录分页查询条件;用户搜索由 admin-server 先解析成 user_id。
type ClaimQuery struct {
Status string
UserID int64
Page int32
PageSize int32
}

View File

@ -0,0 +1,232 @@
package registrationreward
import (
"context"
"strings"
"time"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/registrationreward"
)
const (
registrationRewardTaskID = "registration_reward"
registrationRewardReason = "registration_reward"
)
// Repository 是注册奖励的持久化边界;配置、领取事实和每日份数必须同属 activity-service。
type Repository interface {
GetRegistrationRewardConfig(ctx context.Context) (domain.Config, bool, error)
UpdateRegistrationRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
GetRegistrationRewardEligibility(ctx context.Context, userID int64, rewardDay string, nowMS int64) (domain.Eligibility, error)
PrepareRegistrationRewardClaim(ctx context.Context, command domain.IssueCommand, rewardDay string, nowMS int64) (domain.PrepareResult, error)
MarkRegistrationRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, resourceGrantID string, grantedAtMS int64) (domain.Claim, error)
MarkRegistrationRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
ListRegistrationRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
}
// WalletClient 是注册奖励发放的唯一外部副作用;金币和资源组都依赖 wallet-service 幂等命令。
type WalletClient interface {
CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// Service 承载注册奖励配置、资格判断和注册后自动发放用例。
type Service struct {
repository Repository
wallet WalletClient
now func() time.Time
}
func New(repository Repository, wallet WalletClient) *Service {
return &Service{repository: repository, wallet: wallet, now: time.Now}
}
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
// GetConfig 返回当前配置;未配置时返回 disabled 默认值,避免后台首屏把 404 当异常。
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
config, exists, err := s.repository.GetRegistrationRewardConfig(ctx)
if err != nil {
return domain.Config{}, err
}
if !exists {
return domain.Config{AppCode: appcode.FromContext(ctx), RewardType: domain.RewardTypeCoin}, nil
}
return config, nil
}
// UpdateConfig 更新后台配置;关闭状态也校验奖励参数,避免下次打开时发放一个无效配置。
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
if err := s.requireRepository(); err != nil {
return domain.Config{}, err
}
config.AppCode = appcode.FromContext(ctx)
config.RewardType = normalizeRewardType(config.RewardType)
if err := validateConfig(config); err != nil {
return domain.Config{}, err
}
return s.repository.UpdateRegistrationRewardConfig(ctx, config, s.now().UnixMilli())
}
// GetEligibility 判断“没领过且今天还有份数”;日维度严格使用 UTC不能读取服务器本地时区。
func (s *Service) GetEligibility(ctx context.Context, userID int64) (domain.Eligibility, error) {
if err := s.requireRepository(); err != nil {
return domain.Eligibility{}, err
}
if userID <= 0 {
return domain.Eligibility{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
now := s.now()
return s.repository.GetRegistrationRewardEligibility(ctx, userID, rewardDay(now), now.UnixMilli())
}
// IssueRegistrationReward 在用户注册事务提交后调用;不可领取不是错误,钱包失败才返回错误以便调用方打日志。
func (s *Service) IssueRegistrationReward(ctx context.Context, command domain.IssueCommand) (domain.Claim, domain.Eligibility, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
command.CommandID = strings.TrimSpace(command.CommandID)
if command.UserID <= 0 || command.CommandID == "" {
return domain.Claim{}, domain.Eligibility{}, false, xerr.New(xerr.InvalidArgument, "registration reward command is incomplete")
}
now := s.now()
day := rewardDay(now)
prepared, err := s.repository.PrepareRegistrationRewardClaim(ctx, command, day, now.UnixMilli())
if err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
if !prepared.Prepared {
eligibility, eligibilityErr := s.repository.GetRegistrationRewardEligibility(ctx, command.UserID, day, s.now().UnixMilli())
if eligibilityErr != nil {
return domain.Claim{}, domain.Eligibility{}, false, eligibilityErr
}
if prepared.AlreadyGranted {
return prepared.Claim, eligibility, true, nil
}
return prepared.Claim, eligibility, false, nil
}
if s.wallet == nil {
return domain.Claim{}, domain.Eligibility{}, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
claim := prepared.Claim
var walletTransactionID string
var resourceGrantID string
grantedAtMS := s.now().UnixMilli()
switch claim.RewardType {
case domain.RewardTypeCoin:
resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
Amount: claim.CoinAmount,
TaskType: "exclusive",
TaskId: registrationRewardTaskID,
CycleKey: "lifetime",
Reason: registrationRewardReason,
})
if err != nil {
_ = s.repository.MarkRegistrationRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
return domain.Claim{}, domain.Eligibility{}, false, err
}
walletTransactionID = resp.GetTransactionId()
if resp.GetGrantedAtMs() > 0 {
grantedAtMS = resp.GetGrantedAtMs()
}
case domain.RewardTypeResourceGroup:
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
GroupId: claim.ResourceGroupID,
Reason: registrationRewardReason,
OperatorUserId: claim.UserID,
GrantSource: registrationRewardReason,
})
if err != nil {
_ = s.repository.MarkRegistrationRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
return domain.Claim{}, domain.Eligibility{}, false, err
}
if resp.GetGrant() != nil {
resourceGrantID = resp.GetGrant().GetGrantId()
}
default:
_ = s.repository.MarkRegistrationRewardClaimFailed(ctx, claim.ClaimID, domain.ReasonInvalidConfig, s.now().UnixMilli())
return domain.Claim{}, domain.Eligibility{}, false, xerr.New(xerr.InvalidArgument, "reward_type is invalid")
}
claim, err = s.repository.MarkRegistrationRewardClaimGranted(ctx, claim.ClaimID, walletTransactionID, resourceGrantID, grantedAtMS)
if err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
eligibility, err := s.repository.GetRegistrationRewardEligibility(ctx, command.UserID, day, s.now().UnixMilli())
if err != nil {
return domain.Claim{}, domain.Eligibility{}, false, err
}
return claim, eligibility, true, nil
}
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Status = strings.TrimSpace(query.Status)
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return s.repository.ListRegistrationRewardClaims(ctx, query)
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "registration reward repository is not configured")
}
return nil
}
func normalizeRewardType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validateConfig(config domain.Config) error {
if config.UpdatedByAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
if config.DailyLimit < 0 {
return xerr.New(xerr.InvalidArgument, "daily_limit cannot be negative")
}
switch config.RewardType {
case domain.RewardTypeCoin:
if config.CoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "coin_amount must be greater than zero")
}
case domain.RewardTypeResourceGroup:
if config.ResourceGroupID <= 0 {
return xerr.New(xerr.InvalidArgument, "resource_group_id is required")
}
default:
return xerr.New(xerr.InvalidArgument, "reward_type is invalid")
}
return nil
}
func rewardDay(t time.Time) string {
utc := t.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Format("2006-01-02")
}

View File

@ -0,0 +1,628 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/registrationreward"
)
// GetRegistrationRewardConfig 读取注册奖励当前配置;未配置是正常后台首屏状态。
func (r *Repository) GetRegistrationRewardConfig(ctx context.Context) (domain.Config, bool, error) {
if r == nil || r.db == nil {
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
row := r.db.QueryRowContext(ctx, registrationRewardConfigSelectSQL()+`
WHERE app_code = ?`,
appcode.FromContext(ctx),
)
config, err := scanRegistrationRewardConfig(row)
if errors.Is(err, sql.ErrNoRows) {
return domain.Config{}, false, nil
}
return config, err == nil, err
}
// UpdateRegistrationRewardConfig 原子 upsert 配置created_at_ms 只在首次创建时写入。
func (r *Repository) UpdateRegistrationRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) {
if r == nil || r.db == nil {
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO registration_reward_configs (
app_code, enabled, reward_type, coin_amount, resource_group_id, daily_limit,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
reward_type = VALUES(reward_type),
coin_amount = VALUES(coin_amount),
resource_group_id = VALUES(resource_group_id),
daily_limit = VALUES(daily_limit),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appcode.FromContext(ctx), config.Enabled, config.RewardType, config.CoinAmount, config.ResourceGroupID,
config.DailyLimit, config.UpdatedByAdminID, nowMS, nowMS,
)
if err != nil {
return domain.Config{}, err
}
updated, exists, err := r.GetRegistrationRewardConfig(ctx)
if err != nil {
return domain.Config{}, err
}
if !exists {
return domain.Config{}, xerr.New(xerr.NotFound, "registration reward config not found")
}
return updated, nil
}
// GetRegistrationRewardEligibility 用只读事实判断 App 是否可领取failed claim 不算“已领过”。
func (r *Repository) GetRegistrationRewardEligibility(ctx context.Context, userID int64, rewardDay string, nowMS int64) (domain.Eligibility, error) {
if r == nil || r.db == nil {
return domain.Eligibility{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
config, exists, err := r.GetRegistrationRewardConfig(ctx)
if err != nil {
return domain.Eligibility{}, err
}
used, err := r.registrationRewardDailyUsed(ctx, rewardDay)
if err != nil {
return domain.Eligibility{}, err
}
eligibility := eligibilityFromConfig(config, exists, used, rewardDay, nowMS)
if !eligibility.Enabled {
return eligibility, nil
}
status, err := r.registrationRewardUserClaimStatus(ctx, userID)
if err != nil {
return domain.Eligibility{}, err
}
if status == domain.StatusGranted || status == domain.StatusPending {
eligibility.CanReceive = false
eligibility.Reason = domain.ReasonAlreadyClaimed
return eligibility, nil
}
if eligibility.DailyLimit > 0 && eligibility.DailyUsed >= eligibility.DailyLimit {
eligibility.CanReceive = false
eligibility.Reason = domain.ReasonDailyLimitReached
return eligibility, nil
}
if !validRegistrationRewardConfig(config) {
eligibility.CanReceive = false
eligibility.Reason = domain.ReasonInvalidConfig
return eligibility, nil
}
eligibility.CanReceive = true
eligibility.Reason = domain.ReasonEligible
return eligibility, nil
}
// PrepareRegistrationRewardClaim 在一个 MySQL 事务内完成幂等判断、每日份数预留和 pending claim 写入。
func (r *Repository) PrepareRegistrationRewardClaim(ctx context.Context, command domain.IssueCommand, rewardDay string, nowMS int64) (domain.PrepareResult, error) {
if r == nil || r.db == nil {
return domain.PrepareResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.PrepareResult{}, err
}
defer func() { _ = tx.Rollback() }()
config, exists, err := r.getRegistrationRewardConfigForUpdate(ctx, tx)
if err != nil {
return domain.PrepareResult{}, err
}
if !exists {
return domain.PrepareResult{Reason: domain.ReasonNotConfigured}, tx.Commit()
}
if !config.Enabled {
return domain.PrepareResult{Reason: domain.ReasonDisabled}, tx.Commit()
}
if !validRegistrationRewardConfig(config) {
return domain.PrepareResult{Reason: domain.ReasonInvalidConfig}, tx.Commit()
}
if existing, exists, err := r.getRegistrationRewardClaimByCommandForUpdate(ctx, tx, command.CommandID); err != nil || exists {
if err != nil {
return domain.PrepareResult{}, err
}
if existing.UserID != command.UserID {
return domain.PrepareResult{}, xerr.New(xerr.RequestConflict, "registration reward command payload conflicts")
}
if existing.Status == domain.StatusGranted {
if err := tx.Commit(); err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: existing, AlreadyGranted: true, Reason: domain.ReasonAlreadyClaimed}, nil
}
if existing.Status == domain.StatusFailed {
ok, reason, err := r.reserveRegistrationRewardDailySlot(ctx, tx, config, rewardDay, nowMS)
if err != nil || !ok {
if err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: existing, Reason: reason}, tx.Commit()
}
existing = claimWithConfig(existing, config, rewardDay, nowMS)
if err := r.updateRegistrationRewardClaimPending(ctx, tx, existing); err != nil {
return domain.PrepareResult{}, err
}
}
if err := tx.Commit(); err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: existing, Prepared: true}, nil
}
if existing, exists, err := r.getRegistrationRewardClaimByUserForUpdate(ctx, tx, command.UserID); err != nil || exists {
if err != nil {
return domain.PrepareResult{}, err
}
if existing.Status == domain.StatusGranted {
if err := tx.Commit(); err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: existing, AlreadyGranted: true, Reason: domain.ReasonAlreadyClaimed}, nil
}
if existing.Status == domain.StatusPending {
return domain.PrepareResult{Claim: existing, Reason: domain.ReasonAlreadyClaimed}, tx.Commit()
}
ok, reason, err := r.reserveRegistrationRewardDailySlot(ctx, tx, config, rewardDay, nowMS)
if err != nil || !ok {
if err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: existing, Reason: reason}, tx.Commit()
}
existing = claimWithConfig(existing, config, rewardDay, nowMS)
existing.CommandID = command.CommandID
existing.WalletCommandID = walletRegistrationRewardCommandID(existing.ClaimID)
if err := r.updateRegistrationRewardClaimPending(ctx, tx, existing); err != nil {
return domain.PrepareResult{}, err
}
if err := tx.Commit(); err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: existing, Prepared: true}, nil
}
ok, reason, err := r.reserveRegistrationRewardDailySlot(ctx, tx, config, rewardDay, nowMS)
if err != nil || !ok {
if err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Reason: reason}, tx.Commit()
}
claim := domain.Claim{
ClaimID: idgen.New("rclaim"),
CommandID: command.CommandID,
UserID: command.UserID,
RewardDay: rewardDay,
RewardType: config.RewardType,
CoinAmount: config.CoinAmount,
ResourceGroupID: config.ResourceGroupID,
Status: domain.StatusPending,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
claim.WalletCommandID = walletRegistrationRewardCommandID(claim.ClaimID)
if err := r.insertRegistrationRewardClaim(ctx, tx, claim); err != nil {
return domain.PrepareResult{}, err
}
if err := tx.Commit(); err != nil {
return domain.PrepareResult{}, err
}
return domain.PrepareResult{Claim: claim, Prepared: true}, nil
}
// MarkRegistrationRewardClaimGranted 把外部钱包回执写回 activity 事实,并递增当天 granted 计数。
func (r *Repository) MarkRegistrationRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, resourceGrantID string, grantedAtMS int64) (domain.Claim, error) {
if r == nil || r.db == nil {
return domain.Claim{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Claim{}, err
}
defer func() { _ = tx.Rollback() }()
claim, err := r.getRegistrationRewardClaimByIDForUpdate(ctx, tx, claimID)
if err != nil {
return domain.Claim{}, err
}
if claim.Status == domain.StatusGranted {
if err := tx.Commit(); err != nil {
return domain.Claim{}, err
}
return claim, nil
}
if _, err := tx.ExecContext(ctx, `
UPDATE registration_reward_claims
SET status = 'granted',
wallet_transaction_id = ?,
resource_grant_id = ?,
failure_reason = '',
claimed_at_ms = ?,
updated_at_ms = ?
WHERE app_code = ? AND claim_id = ?`,
walletTransactionID, resourceGrantID, grantedAtMS, grantedAtMS, appcode.FromContext(ctx), claimID,
); err != nil {
return domain.Claim{}, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE registration_reward_daily_counters
SET granted_count = granted_count + 1, updated_at_ms = ?
WHERE app_code = ? AND reward_day = ?`,
grantedAtMS, appcode.FromContext(ctx), claim.RewardDay,
); err != nil {
return domain.Claim{}, err
}
if err := tx.Commit(); err != nil {
return domain.Claim{}, err
}
claim.Status = domain.StatusGranted
claim.WalletTransactionID = walletTransactionID
claim.ResourceGrantID = resourceGrantID
claim.FailureReason = ""
claim.ClaimedAtMS = grantedAtMS
claim.UpdatedAtMS = grantedAtMS
return claim, nil
}
// MarkRegistrationRewardClaimFailed 释放 pending 预留份数;这样钱包短暂失败不会永久占用每日额度。
func (r *Repository) MarkRegistrationRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
claim, err := r.getRegistrationRewardClaimByIDForUpdate(ctx, tx, claimID)
if err != nil {
return err
}
if claim.Status == domain.StatusGranted {
return tx.Commit()
}
if claim.Status == domain.StatusPending {
if _, err := tx.ExecContext(ctx, `
UPDATE registration_reward_daily_counters
SET reserved_count = IF(reserved_count > 0, reserved_count - 1, 0),
failed_count = failed_count + 1,
updated_at_ms = ?
WHERE app_code = ? AND reward_day = ?`,
nowMS, appcode.FromContext(ctx), claim.RewardDay,
); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE registration_reward_claims
SET status = 'failed', failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND claim_id = ?`,
truncateTaskFailure(failureReason), nowMS, appcode.FromContext(ctx), claimID,
); err != nil {
return err
}
return tx.Commit()
}
// ListRegistrationRewardClaims 返回后台领取事实列表;按领取/创建时间倒序便于运营复核最近发放。
func (r *Repository) ListRegistrationRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
where, args := registrationRewardClaimWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM registration_reward_claims `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
pageSize := normalizePageSize(query.PageSize)
page := query.Page
if page <= 0 {
page = 1
}
args = append(args, pageSize, (page-1)*pageSize)
rows, err := r.db.QueryContext(ctx, registrationRewardClaimSelectSQL()+`
`+where+`
ORDER BY created_at_ms DESC, claim_id DESC
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
return scanRegistrationRewardClaims(rows, total)
}
func (r *Repository) getRegistrationRewardConfigForUpdate(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
row := tx.QueryRowContext(ctx, registrationRewardConfigSelectSQL()+`
WHERE app_code = ?
FOR UPDATE`,
appcode.FromContext(ctx),
)
config, err := scanRegistrationRewardConfig(row)
if errors.Is(err, sql.ErrNoRows) {
return domain.Config{}, false, nil
}
return config, err == nil, err
}
func (r *Repository) reserveRegistrationRewardDailySlot(ctx context.Context, tx *sql.Tx, config domain.Config, rewardDay string, nowMS int64) (bool, string, error) {
if _, err := tx.ExecContext(ctx, `
INSERT INTO registration_reward_daily_counters (
app_code, reward_day, reserved_count, granted_count, failed_count, updated_at_ms
) VALUES (?, ?, 0, 0, 0, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appcode.FromContext(ctx), rewardDay, nowMS,
); err != nil {
return false, "", err
}
var reserved int64
if err := tx.QueryRowContext(ctx, `
SELECT reserved_count
FROM registration_reward_daily_counters
WHERE app_code = ? AND reward_day = ?
FOR UPDATE`,
appcode.FromContext(ctx), rewardDay,
).Scan(&reserved); err != nil {
return false, "", err
}
if config.DailyLimit > 0 && reserved >= config.DailyLimit {
return false, domain.ReasonDailyLimitReached, nil
}
if _, err := tx.ExecContext(ctx, `
UPDATE registration_reward_daily_counters
SET reserved_count = reserved_count + 1, updated_at_ms = ?
WHERE app_code = ? AND reward_day = ?`,
nowMS, appcode.FromContext(ctx), rewardDay,
); err != nil {
return false, "", err
}
return true, "", nil
}
func (r *Repository) registrationRewardDailyUsed(ctx context.Context, rewardDay string) (int64, error) {
var used int64
err := r.db.QueryRowContext(ctx, `
SELECT reserved_count
FROM registration_reward_daily_counters
WHERE app_code = ? AND reward_day = ?`,
appcode.FromContext(ctx), rewardDay,
).Scan(&used)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return used, err
}
func (r *Repository) registrationRewardUserClaimStatus(ctx context.Context, userID int64) (string, error) {
var status string
err := r.db.QueryRowContext(ctx, `
SELECT status
FROM registration_reward_claims
WHERE app_code = ? AND user_id = ?`,
appcode.FromContext(ctx), userID,
).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return status, err
}
func (r *Repository) getRegistrationRewardClaimByCommandForUpdate(ctx context.Context, tx *sql.Tx, commandID string) (domain.Claim, bool, error) {
row := tx.QueryRowContext(ctx, registrationRewardClaimSelectSQL()+`
WHERE app_code = ? AND command_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), commandID,
)
claim, err := scanRegistrationRewardClaim(row)
if errors.Is(err, sql.ErrNoRows) {
return domain.Claim{}, false, nil
}
return claim, err == nil, err
}
func (r *Repository) getRegistrationRewardClaimByUserForUpdate(ctx context.Context, tx *sql.Tx, userID int64) (domain.Claim, bool, error) {
row := tx.QueryRowContext(ctx, registrationRewardClaimSelectSQL()+`
WHERE app_code = ? AND user_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), userID,
)
claim, err := scanRegistrationRewardClaim(row)
if errors.Is(err, sql.ErrNoRows) {
return domain.Claim{}, false, nil
}
return claim, err == nil, err
}
func (r *Repository) getRegistrationRewardClaimByIDForUpdate(ctx context.Context, tx *sql.Tx, claimID string) (domain.Claim, error) {
row := tx.QueryRowContext(ctx, registrationRewardClaimSelectSQL()+`
WHERE app_code = ? AND claim_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), claimID,
)
claim, err := scanRegistrationRewardClaim(row)
if errors.Is(err, sql.ErrNoRows) {
return domain.Claim{}, xerr.New(xerr.NotFound, "registration reward claim not found")
}
return claim, err
}
func (r *Repository) insertRegistrationRewardClaim(ctx context.Context, tx *sql.Tx, claim domain.Claim) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO registration_reward_claims (
app_code, claim_id, command_id, user_id, reward_day, reward_type,
coin_amount, resource_group_id, status, wallet_command_id,
wallet_transaction_id, resource_grant_id, failure_reason, claimed_at_ms,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', '', 0, ?, ?)`,
appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.RewardDay, claim.RewardType,
claim.CoinAmount, claim.ResourceGroupID, claim.Status, claim.WalletCommandID, claim.CreatedAtMS, claim.UpdatedAtMS,
)
return err
}
func (r *Repository) updateRegistrationRewardClaimPending(ctx context.Context, tx *sql.Tx, claim domain.Claim) error {
_, err := tx.ExecContext(ctx, `
UPDATE registration_reward_claims
SET command_id = ?,
reward_day = ?,
reward_type = ?,
coin_amount = ?,
resource_group_id = ?,
status = 'pending',
wallet_command_id = ?,
wallet_transaction_id = '',
resource_grant_id = '',
failure_reason = '',
claimed_at_ms = 0,
updated_at_ms = ?
WHERE app_code = ? AND claim_id = ?`,
claim.CommandID, claim.RewardDay, claim.RewardType, claim.CoinAmount, claim.ResourceGroupID,
claim.WalletCommandID, claim.UpdatedAtMS, appcode.FromContext(ctx), claim.ClaimID,
)
return err
}
func scanRegistrationRewardClaims(rows *sql.Rows, total int64) ([]domain.Claim, int64, error) {
items := make([]domain.Claim, 0)
for rows.Next() {
item, err := scanRegistrationRewardClaim(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
func scanRegistrationRewardConfig(row rowScanner) (domain.Config, error) {
var config domain.Config
err := row.Scan(
&config.AppCode,
&config.Enabled,
&config.RewardType,
&config.CoinAmount,
&config.ResourceGroupID,
&config.DailyLimit,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
)
return config, err
}
func scanRegistrationRewardClaim(row rowScanner) (domain.Claim, error) {
var claim domain.Claim
err := row.Scan(
&claim.ClaimID,
&claim.CommandID,
&claim.UserID,
&claim.RewardDay,
&claim.RewardType,
&claim.CoinAmount,
&claim.ResourceGroupID,
&claim.Status,
&claim.WalletCommandID,
&claim.WalletTransactionID,
&claim.ResourceGrantID,
&claim.FailureReason,
&claim.ClaimedAtMS,
&claim.CreatedAtMS,
&claim.UpdatedAtMS,
)
return claim, err
}
func registrationRewardConfigSelectSQL() string {
return `SELECT app_code, enabled, reward_type, coin_amount, resource_group_id, daily_limit,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM registration_reward_configs `
}
func registrationRewardClaimSelectSQL() string {
return `SELECT claim_id, command_id, user_id, reward_day, reward_type, coin_amount, resource_group_id,
status, wallet_command_id, wallet_transaction_id, resource_grant_id, failure_reason,
claimed_at_ms, created_at_ms, updated_at_ms
FROM registration_reward_claims `
}
func registrationRewardClaimWhere(ctx context.Context, query domain.ClaimQuery) (string, []any) {
conditions := []string{"app_code = ?"}
args := []any{appcode.FromContext(ctx)}
if query.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, query.Status)
}
if query.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, query.UserID)
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
func eligibilityFromConfig(config domain.Config, exists bool, used int64, rewardDay string, nowMS int64) domain.Eligibility {
reason := domain.ReasonEligible
if !exists {
reason = domain.ReasonNotConfigured
} else if !config.Enabled {
reason = domain.ReasonDisabled
}
remaining := int64(0)
if config.DailyLimit > 0 && used < config.DailyLimit {
remaining = config.DailyLimit - used
}
return domain.Eligibility{
CanReceive: exists && config.Enabled,
Reason: reason,
Enabled: exists && config.Enabled,
RewardType: config.RewardType,
CoinAmount: config.CoinAmount,
ResourceGroupID: config.ResourceGroupID,
DailyLimit: config.DailyLimit,
DailyUsed: used,
DailyRemaining: remaining,
RewardDay: rewardDay,
ServerTimeMS: nowMS,
}
}
func validRegistrationRewardConfig(config domain.Config) bool {
switch config.RewardType {
case domain.RewardTypeCoin:
return config.CoinAmount > 0
case domain.RewardTypeResourceGroup:
return config.ResourceGroupID > 0
default:
return false
}
}
func claimWithConfig(claim domain.Claim, config domain.Config, rewardDay string, nowMS int64) domain.Claim {
claim.RewardDay = rewardDay
claim.RewardType = config.RewardType
claim.CoinAmount = config.CoinAmount
claim.ResourceGroupID = config.ResourceGroupID
claim.Status = domain.StatusPending
claim.FailureReason = ""
claim.ClaimedAtMS = 0
claim.UpdatedAtMS = nowMS
return claim
}
func walletRegistrationRewardCommandID(claimID string) string {
return fmt.Sprintf("wreg_%s", claimID)
}

View File

@ -0,0 +1,154 @@
package grpc
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/registrationreward"
service "hyapp/services/activity-service/internal/service/registrationreward"
)
// RegistrationRewardServer 暴露 App 查询和 user-service 注册后发放接口。
type RegistrationRewardServer struct {
activityv1.UnimplementedRegistrationRewardServiceServer
svc *service.Service
}
func NewRegistrationRewardServer(svc *service.Service) *RegistrationRewardServer {
return &RegistrationRewardServer{svc: svc}
}
func (s *RegistrationRewardServer) GetRegistrationRewardEligibility(ctx context.Context, req *activityv1.GetRegistrationRewardEligibilityRequest) (*activityv1.GetRegistrationRewardEligibilityResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
eligibility, err := s.svc.GetEligibility(ctx, req.GetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetRegistrationRewardEligibilityResponse{Eligibility: eligibilityToProto(eligibility)}, nil
}
func (s *RegistrationRewardServer) IssueRegistrationReward(ctx context.Context, req *activityv1.IssueRegistrationRewardRequest) (*activityv1.IssueRegistrationRewardResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
claim, eligibility, issued, err := s.svc.IssueRegistrationReward(ctx, domain.IssueCommand{
UserID: req.GetUserId(),
CommandID: req.GetCommandId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.IssueRegistrationRewardResponse{
Claim: registrationRewardClaimToProto(claim),
Eligibility: eligibilityToProto(eligibility),
Issued: issued,
}, nil
}
// AdminRegistrationRewardServer 暴露后台配置和领取记录查询入口。
type AdminRegistrationRewardServer struct {
activityv1.UnimplementedAdminRegistrationRewardServiceServer
svc *service.Service
}
func NewAdminRegistrationRewardServer(svc *service.Service) *AdminRegistrationRewardServer {
return &AdminRegistrationRewardServer{svc: svc}
}
func (s *AdminRegistrationRewardServer) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, err := s.svc.GetConfig(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetRegistrationRewardConfigResponse{Config: registrationRewardConfigToProto(config)}, nil
}
func (s *AdminRegistrationRewardServer) UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, err := s.svc.UpdateConfig(ctx, domain.Config{
Enabled: req.GetEnabled(),
RewardType: req.GetRewardType(),
CoinAmount: req.GetCoinAmount(),
ResourceGroupID: req.GetResourceGroupId(),
DailyLimit: req.GetDailyLimit(),
UpdatedByAdminID: req.GetOperatorAdminId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpdateRegistrationRewardConfigResponse{Config: registrationRewardConfigToProto(config)}, nil
}
func (s *AdminRegistrationRewardServer) ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
claims, total, err := s.svc.ListClaims(ctx, domain.ClaimQuery{
Status: req.GetStatus(),
UserID: req.GetUserId(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &activityv1.ListRegistrationRewardClaimsResponse{Claims: make([]*activityv1.RegistrationRewardClaim, 0, len(claims)), Total: total}
for _, claim := range claims {
resp.Claims = append(resp.Claims, registrationRewardClaimToProto(claim))
}
return resp, nil
}
func registrationRewardConfigToProto(config domain.Config) *activityv1.RegistrationRewardConfig {
return &activityv1.RegistrationRewardConfig{
AppCode: config.AppCode,
Enabled: config.Enabled,
RewardType: config.RewardType,
CoinAmount: config.CoinAmount,
ResourceGroupId: config.ResourceGroupID,
DailyLimit: config.DailyLimit,
UpdatedByAdminId: config.UpdatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
}
func registrationRewardClaimToProto(claim domain.Claim) *activityv1.RegistrationRewardClaim {
if claim.ClaimID == "" {
return nil
}
return &activityv1.RegistrationRewardClaim{
ClaimId: claim.ClaimID,
CommandId: claim.CommandID,
UserId: claim.UserID,
RewardDay: claim.RewardDay,
RewardType: claim.RewardType,
CoinAmount: claim.CoinAmount,
ResourceGroupId: claim.ResourceGroupID,
Status: claim.Status,
WalletCommandId: claim.WalletCommandID,
WalletTransactionId: claim.WalletTransactionID,
ResourceGrantId: claim.ResourceGrantID,
FailureReason: claim.FailureReason,
ClaimedAtMs: claim.ClaimedAtMS,
CreatedAtMs: claim.CreatedAtMS,
UpdatedAtMs: claim.UpdatedAtMS,
}
}
func eligibilityToProto(item domain.Eligibility) *activityv1.RegistrationRewardEligibility {
return &activityv1.RegistrationRewardEligibility{
CanReceive: item.CanReceive,
Reason: item.Reason,
Enabled: item.Enabled,
RewardType: item.RewardType,
CoinAmount: item.CoinAmount,
ResourceGroupId: item.ResourceGroupID,
DailyLimit: item.DailyLimit,
DailyUsed: item.DailyUsed,
DailyRemaining: item.DailyRemaining,
RewardDay: item.RewardDay,
ServerTimeMs: item.ServerTimeMS,
}
}

View File

@ -91,6 +91,7 @@ func New(cfg config.Config) (*App, error) {
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
@ -118,6 +119,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetWalletClient(walletClient)
handler.SetMessageInboxClient(messageClient)
handler.SetTaskClient(taskClient)
handler.SetRegistrationRewardClient(registrationRewardClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
if appConfigReader != nil {

View File

@ -21,6 +21,12 @@ const listAppBannersSQL = `
AND (region_id = 0 OR region_id = ?)
AND (country_code = '' OR country_code = ?)
ORDER BY sort_order ASC, id DESC`
const latestAppVersionSQL = `
SELECT id, app_code, platform, version, build_number, force_update, download_url, COALESCE(description, ''), updated_at_ms
FROM admin_app_versions
WHERE app_code = ? AND platform = ?
ORDER BY build_number DESC, id DESC
LIMIT 1`
// H5Link 是 gateway 下发给 App 的 H5 入口配置。
type H5Link struct {
@ -52,10 +58,31 @@ type Banner struct {
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// VersionQuery 是 App 检查更新的公开筛选条件。
type VersionQuery struct {
AppCode string
Platform string
CurrentBuildNumber int64
}
// Version 是后台 APP配置/版本管理 中当前平台的最新 App 版本。
type Version struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Platform string `json:"platform"`
Version string `json:"version"`
BuildNumber int64 `json:"build_number"`
ForceUpdate bool `json:"force_update"`
DownloadURL string `json:"download_url"`
Description string `json:"description"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// Reader 是 HTTP 层读取 H5 配置的最小依赖。
type Reader interface {
ListH5Links(ctx context.Context) ([]H5Link, error)
ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error)
LatestVersion(ctx context.Context, query VersionQuery) (Version, error)
}
type h5LinkDefinition struct {
@ -188,10 +215,38 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
return items, nil
}
// LatestVersion 返回指定 App 和平台的最高 build_number 版本;未配置时返回空版本,方便 App 端按无更新处理。
func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) {
if r == nil || r.db == nil {
return Version{}, errors.New("app config reader is not configured")
}
var item Version
err := r.db.QueryRowContext(ctx, latestAppVersionSQL, normalizeAppCode(query.AppCode), normalizePlatform(query.Platform)).Scan(
&item.ID,
&item.AppCode,
&item.Platform,
&item.Version,
&item.BuildNumber,
&item.ForceUpdate,
&item.DownloadURL,
&item.Description,
&item.UpdatedAtMs,
)
if errors.Is(err, sql.ErrNoRows) {
return Version{AppCode: normalizeAppCode(query.AppCode), Platform: normalizePlatform(query.Platform)}, nil
}
if err != nil {
return Version{}, err
}
return item, nil
}
// StaticReader 给测试和临时环境提供内存版 H5 配置读取。
type StaticReader struct {
Links []H5Link
Banners []Banner
Version Version
Err error
}
@ -217,6 +272,21 @@ func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error
return out, nil
}
// LatestVersion 返回预置版本配置。
func (r StaticReader) LatestVersion(_ context.Context, query VersionQuery) (Version, error) {
if r.Err != nil {
return Version{}, r.Err
}
item := r.Version
if item.AppCode == "" {
item.AppCode = normalizeAppCode(query.AppCode)
}
if item.Platform == "" {
item.Platform = normalizePlatform(query.Platform)
}
return item, nil
}
func normalizeAppCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {

View File

@ -0,0 +1,25 @@
package client
import (
"context"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
)
// RegistrationRewardClient abstracts gateway access to activity-service registration reward reads.
type RegistrationRewardClient interface {
GetRegistrationRewardEligibility(ctx context.Context, req *activityv1.GetRegistrationRewardEligibilityRequest) (*activityv1.GetRegistrationRewardEligibilityResponse, error)
}
type grpcRegistrationRewardClient struct {
client activityv1.RegistrationRewardServiceClient
}
func NewGRPCRegistrationRewardClient(conn *grpc.ClientConn) RegistrationRewardClient {
return &grpcRegistrationRewardClient{client: activityv1.NewRegistrationRewardServiceClient(conn)}
}
func (c *grpcRegistrationRewardClient) GetRegistrationRewardEligibility(ctx context.Context, req *activityv1.GetRegistrationRewardEligibilityRequest) (*activityv1.GetRegistrationRewardEligibilityResponse, error) {
return c.client.GetRegistrationRewardEligibility(ctx, req)
}

View File

@ -23,7 +23,6 @@ type RoomClient interface {
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error)
TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error)
MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error)
KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error)
UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error)
@ -136,10 +135,6 @@ func (c *grpcRoomClient) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAd
return c.client.SetRoomAdmin(ctx, req)
}
func (c *grpcRoomClient) TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) {
return c.client.TransferRoomHost(ctx, req)
}
func (c *grpcRoomClient) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
return c.client.MuteUser(ctx, req)
}

View File

@ -2,7 +2,6 @@ package http
import (
"context"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"strconv"
"strings"
@ -10,12 +9,14 @@ import (
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
// appConfigReader 是 gateway 对后台 App 配置的只读依赖。
type appConfigReader interface {
ListH5Links(ctx context.Context) ([]appconfig.H5Link, error)
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error)
}
type appBootstrapBottomTab struct {
@ -36,6 +37,20 @@ type appBootstrapResponse struct {
ConfigVersions map[string]string `json:"config_versions"`
}
type appVersionResponse struct {
AppCode string `json:"app_code"`
Platform string `json:"platform"`
Version string `json:"version"`
BuildNumber int64 `json:"build_number"`
ForceUpdate bool `json:"force_update"`
DownloadURL string `json:"download_url"`
Description string `json:"description"`
HasUpdate bool `json:"has_update"`
CurrentBuildNumber int64 `json:"current_build_number"`
UpdatedAtMs int64 `json:"updated_at_ms"`
ServerTimeMs int64 `json:"server_time_ms"`
}
// getAppBootstrap 返回 App 启动状态机需要的轻量配置摘要。
// bootstrap 只下发版本、开关和配置版本号,不返回 banner、礼物或资源大列表。
func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Request) {
@ -108,6 +123,43 @@ func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Reque
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
// getAppVersion 返回当前平台最新版本App 用 build_number 判定是否需要提示升级。
func (h *Handler) getAppVersion(writer http.ResponseWriter, request *http.Request) {
if h.appConfigReader == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
platform := normalizeAppPlatform(firstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"))
if platform != "android" && platform != "ios" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "platform is invalid")
return
}
currentBuildNumber := optionalInt64QueryOrHeader(request, "build_number", "X-App-Build-Number", "X-Build-Number")
item, err := h.appConfigReader.LatestVersion(request.Context(), appconfig.VersionQuery{
AppCode: appcode.FromContext(request.Context()),
Platform: platform,
CurrentBuildNumber: currentBuildNumber,
})
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
httpkit.WriteOK(writer, request, appVersionResponse{
AppCode: item.AppCode,
Platform: platform,
Version: item.Version,
BuildNumber: item.BuildNumber,
ForceUpdate: item.ForceUpdate && item.BuildNumber > currentBuildNumber,
DownloadURL: item.DownloadURL,
Description: item.Description,
HasUpdate: item.BuildNumber > currentBuildNumber,
CurrentBuildNumber: currentBuildNumber,
UpdatedAtMs: item.UpdatedAtMs,
ServerTimeMs: time.Now().UTC().UnixMilli(),
})
}
func firstQueryOrHeader(request *http.Request, queryKey string, headerNames ...string) string {
if value := strings.TrimSpace(request.URL.Query().Get(queryKey)); value != "" {
return value
@ -115,6 +167,18 @@ func firstQueryOrHeader(request *http.Request, queryKey string, headerNames ...s
return firstHeader(request, headerNames...)
}
func optionalInt64QueryOrHeader(request *http.Request, queryKey string, headerNames ...string) int64 {
value := firstQueryOrHeader(request, queryKey, headerNames...)
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func optionalInt64Query(request *http.Request, key string) int64 {
value := strings.TrimSpace(request.URL.Query().Get(key))
if value == "" {
@ -126,3 +190,7 @@ func optionalInt64Query(request *http.Request, key string) int64 {
}
return parsed
}
func normalizeAppPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@ -12,15 +12,22 @@ import (
)
type rechargeProductData struct {
ProductID int64 `json:"product_id"`
ProductCode string `json:"product_code"`
Channel string `json:"channel"`
CurrencyCode string `json:"currency_code"`
CoinAmount int64 `json:"coin_amount"`
AmountMinor int64 `json:"amount_minor"`
PolicyVersion string `json:"policy_version"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sort_order"`
ProductID int64 `json:"product_id"`
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
Description string `json:"description"`
Platform string `json:"platform"`
Channel string `json:"channel"`
CurrencyCode string `json:"currency_code"`
CoinAmount int64 `json:"coin_amount"`
AmountMinor int64 `json:"amount_minor"`
AmountMicro int64 `json:"amount_micro"`
PolicyVersion string `json:"policy_version"`
RegionIDs []int64 `json:"region_ids"`
ResourceAssetType string `json:"resource_asset_type"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sort_order"`
}
type diamondExchangeRuleData struct {
@ -143,6 +150,7 @@ func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
RegionId: profileResp.GetUser().GetRegionId(),
Platform: firstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -179,6 +187,15 @@ func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *
// listWalletTransactions 返回当前用户钱包流水分页。
func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *http.Request) {
h.listWalletTransactionsByAsset(writer, request, strings.TrimSpace(request.URL.Query().Get("asset_type")), 20)
}
// listCoinTransactions 返回当前用户金币流水分页;默认 page_size=30 对齐 App 最近 30 条展示。
func (h *Handler) listCoinTransactions(writer http.ResponseWriter, request *http.Request) {
h.listWalletTransactionsByAsset(writer, request, "COIN", 30)
}
func (h *Handler) listWalletTransactionsByAsset(writer http.ResponseWriter, request *http.Request, assetType string, defaultPageSize int32) {
if h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
@ -188,7 +205,7 @@ func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *ht
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
pageSize, ok := parsePositiveInt32Query(request, "page_size", defaultPageSize)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
@ -197,7 +214,7 @@ func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *ht
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
AssetType: strings.TrimSpace(request.URL.Query().Get("asset_type")),
AssetType: assetType,
Page: page,
PageSize: pageSize,
})
@ -257,15 +274,22 @@ func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProduct
return rechargeProductData{}
}
return rechargeProductData{
ProductID: product.GetProductId(),
ProductCode: product.GetProductCode(),
Channel: product.GetChannel(),
CurrencyCode: product.GetCurrencyCode(),
CoinAmount: product.GetCoinAmount(),
AmountMinor: product.GetAmountMinor(),
PolicyVersion: product.GetPolicyVersion(),
Enabled: product.GetEnabled(),
SortOrder: product.GetSortOrder(),
ProductID: product.GetProductId(),
ProductCode: product.GetProductCode(),
ProductName: product.GetProductName(),
Description: product.GetDescription(),
Platform: product.GetPlatform(),
Channel: product.GetChannel(),
CurrencyCode: product.GetCurrencyCode(),
CoinAmount: product.GetCoinAmount(),
AmountMinor: product.GetAmountMinor(),
AmountMicro: product.GetAmountMicro(),
PolicyVersion: product.GetPolicyVersion(),
RegionIDs: product.GetRegionIds(),
ResourceAssetType: product.GetResourceAssetType(),
Status: product.GetStatus(),
Enabled: product.GetEnabled(),
SortOrder: product.GetSortOrder(),
}
}

View File

@ -36,6 +36,7 @@ type Handler struct {
walletClient client.WalletClient
messageClient client.MessageInboxClient
taskClient client.TaskClient
registrationReward client.RegistrationRewardClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
appConfigReader appConfigReader
@ -173,6 +174,11 @@ func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
h.taskClient = taskClient
}
// SetRegistrationRewardClient 注入 activity-service 注册奖励查询 client。
func (h *Handler) SetRegistrationRewardClient(registrationReward client.RegistrationRewardClient) {
h.registrationReward = registrationReward
}
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient

View File

@ -39,6 +39,7 @@ type AppHandlers struct {
GetAppBootstrap http.HandlerFunc
ListH5Links http.HandlerFunc
ListAppBanners http.HandlerFunc
GetAppVersion http.HandlerFunc
ListResources http.HandlerFunc
GetResourceGroup http.HandlerFunc
ListGifts http.HandlerFunc
@ -103,7 +104,6 @@ type RoomHandlers struct {
SetMicSeatLock http.HandlerFunc
SetChatEnabled http.HandlerFunc
SetRoomAdmin http.HandlerFunc
TransferRoomHost http.HandlerFunc
MuteUser http.HandlerFunc
KickUser http.HandlerFunc
UnbanUser http.HandlerFunc
@ -119,8 +119,9 @@ type MessageHandlers struct {
}
type TaskHandlers struct {
ListTaskTabs http.HandlerFunc
ClaimTaskReward http.HandlerFunc
ListTaskTabs http.HandlerFunc
ClaimTaskReward http.HandlerFunc
GetRegistrationRewardEligibility http.HandlerFunc
}
type WalletHandlers struct {
@ -129,6 +130,7 @@ type WalletHandlers struct {
ListRechargeProducts http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ApplyWithdrawal http.HandlerFunc
ListCoinTransactions http.HandlerFunc
ListWalletTransactions http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc
}
@ -205,6 +207,7 @@ func (r routes) registerAppRoutes() {
r.public("/app/bootstrap", "", h.GetAppBootstrap)
r.public("/app/h5-links", "", h.ListH5Links)
r.public("/app/banners", "", h.ListAppBanners)
r.public("/app/version", http.MethodGet, h.GetAppVersion)
r.public("/resources", "", h.ListResources)
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
r.public("/gifts", "", h.ListGifts)
@ -272,7 +275,6 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/mic/lock", http.MethodPost, h.SetMicSeatLock)
r.profile("/rooms/chat/enabled", http.MethodPost, h.SetChatEnabled)
r.profile("/rooms/admin/set", http.MethodPost, h.SetRoomAdmin)
r.profile("/rooms/host/transfer", http.MethodPost, h.TransferRoomHost)
r.profile("/rooms/user/mute", http.MethodPost, h.MuteUser)
r.profile("/rooms/user/kick", http.MethodPost, h.KickUser)
r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser)
@ -290,6 +292,7 @@ func (r routes) registerMessageRoutes() {
func (r routes) registerTaskRoutes() {
h := r.config.Task
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility)
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
r.profile("/tasks/claim", http.MethodPost, h.ClaimTaskReward)
}
@ -301,6 +304,7 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
}

View File

@ -0,0 +1,59 @@
package http
import (
"net/http"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type registrationRewardEligibilityData struct {
CanReceive bool `json:"can_receive"`
Reason string `json:"reason"`
Enabled bool `json:"enabled"`
RewardType string `json:"reward_type"`
CoinAmount int64 `json:"coin_amount"`
ResourceGroupID int64 `json:"resource_group_id"`
DailyLimit int64 `json:"daily_limit"`
DailyUsed int64 `json:"daily_used"`
DailyRemaining int64 `json:"daily_remaining"`
RewardDay string `json:"reward_day"`
ServerTimeMS int64 `json:"server_time_ms"`
}
// getRegistrationRewardEligibility 只查询资格,不执行发放;注册后的实际发放由 user-service 触发。
func (h *Handler) getRegistrationRewardEligibility(writer http.ResponseWriter, request *http.Request) {
if h.registrationReward == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.registrationReward.GetRegistrationRewardEligibility(request.Context(), &activityv1.GetRegistrationRewardEligibilityRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, registrationRewardEligibilityFromProto(resp.GetEligibility()))
}
func registrationRewardEligibilityFromProto(item *activityv1.RegistrationRewardEligibility) registrationRewardEligibilityData {
if item == nil {
return registrationRewardEligibilityData{}
}
return registrationRewardEligibilityData{
CanReceive: item.GetCanReceive(),
Reason: item.GetReason(),
Enabled: item.GetEnabled(),
RewardType: item.GetRewardType(),
CoinAmount: item.GetCoinAmount(),
ResourceGroupID: item.GetResourceGroupId(),
DailyLimit: item.GetDailyLimit(),
DailyUsed: item.GetDailyUsed(),
DailyRemaining: item.GetDailyRemaining(),
RewardDay: item.GetRewardDay(),
ServerTimeMS: item.GetServerTimeMs(),
}
}

View File

@ -46,7 +46,6 @@ type fakeRoomClient struct {
lastMicSeatLock *roomv1.SetMicSeatLockRequest
lastChatEnabled *roomv1.SetChatEnabledRequest
lastSetAdmin *roomv1.SetRoomAdminRequest
lastTransferHost *roomv1.TransferRoomHostRequest
lastMute *roomv1.MuteUserRequest
lastKick *roomv1.KickUserRequest
lastUnban *roomv1.UnbanUserRequest
@ -79,7 +78,6 @@ func (f *fakeRoomClient) UpdateRoomProfile(_ context.Context, req *roomv1.Update
Room: &roomv1.RoomSnapshot{
RoomId: req.GetMeta().GetRoomId(),
OwnerUserId: req.GetMeta().GetActorUserId(),
HostUserId: req.GetMeta().GetActorUserId(),
Mode: "voice",
Status: "active",
ChatEnabled: true,
@ -113,12 +111,11 @@ func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest
Room: &roomv1.RoomSnapshot{
RoomId: req.GetMeta().GetRoomId(),
OwnerUserId: 1001,
HostUserId: 1002,
Mode: "voice",
Status: "active",
ChatEnabled: true,
MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2, UserId: 1002, PublishState: "publishing", MicSessionId: "mic-1002"}},
OnlineUsers: []*roomv1.RoomUser{{UserId: userID, Role: role}, {UserId: 1002, Role: "host"}},
OnlineUsers: []*roomv1.RoomUser{{UserId: userID, Role: role}, {UserId: 1002, Role: "admin"}},
GiftRank: []*roomv1.RankItem{{UserId: 1002, Score: 200, GiftValue: 200}},
RoomExt: map[string]string{"title": "Room", "cover_url": "https://cdn.example/room.png", "description": "welcome", "room_short_id": "100001"},
Heat: 99,
@ -183,11 +180,6 @@ func (f *fakeRoomClient) SetRoomAdmin(_ context.Context, req *roomv1.SetRoomAdmi
return &roomv1.SetRoomAdminResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil
}
func (f *fakeRoomClient) TransferRoomHost(_ context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) {
f.lastTransferHost = req
return &roomv1.TransferRoomHostResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 10}}, nil
}
func (f *fakeRoomClient) MuteUser(_ context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
f.lastMute = req
return &roomv1.MuteUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 11}}, nil
@ -1246,15 +1238,6 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
return client.lastSetAdmin.GetMeta()
},
},
{
name: "transfer_host",
path: "/api/v1/rooms/host/transfer",
body: `{"room_id":"room-1","command_id":"cmd-host-1","target_user_id":43}`,
commandID: "cmd-host-1",
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
return client.lastTransferHost.GetMeta()
},
},
{
name: "mute",
path: "/api/v1/rooms/user/mute",
@ -1337,12 +1320,11 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
Room: &roomv1.RoomSnapshot{
RoomId: "room_1001",
OwnerUserId: 101,
HostUserId: 102,
Mode: "voice",
Status: "active",
ChatEnabled: true,
MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing", MicSessionId: "mic-102"}, {SeatNo: 2, Locked: true}},
OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}, {UserId: 102, Role: "host"}},
OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}, {UserId: 102, Role: "admin"}},
GiftRank: []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}},
RoomExt: map[string]string{"title": "Live Room", "cover_url": "https://cdn.example/cover.png", "description": "welcome"},
Heat: 88,
@ -1397,7 +1379,7 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if rtc["available"] != true || token["str_room_id"] != "room_1001" || token["rtc_user_id"] != "42" {
t.Fatalf("join response must embed usable rtc token: rtc=%+v token=%+v", rtc, token)
}
if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,102,42,301" {
if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,42,102,301" {
t.Fatalf("join response must batch only first-screen profile users: %+v", profileClient.lastBatch)
}
}
@ -1960,6 +1942,49 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
}
}
func TestGetAppVersionReturnsLatestVersionAndUpdateDecision(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetAppConfigReader(appconfig.StaticReader{Version: appconfig.Version{
AppCode: "lalu",
Platform: "ios",
Version: "1.2.0",
BuildNumber: 120,
ForceUpdate: true,
DownloadURL: "https://apps.apple.com/app/lalu",
Description: "bug fixes",
UpdatedAtMs: 1700000003000,
}})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/version?build_number=100", nil)
request.Header.Set("X-Request-ID", "req-app-version")
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("X-App-Platform", "ios")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if data["platform"] != "ios" ||
data["version"] != "1.2.0" ||
data["build_number"] != float64(120) ||
data["current_build_number"] != float64(100) ||
data["has_update"] != true ||
data["force_update"] != true ||
data["download_url"] != "https://apps.apple.com/app/lalu" {
t.Fatalf("app version response mismatch: %+v", data)
}
}
func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
hostClient := &fakeUserHostClient{
hostProfile: &userv1.HostProfile{
@ -2632,6 +2657,119 @@ func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
}
}
func TestListCoinTransactionsForcesCoinAndDefaultsPageSize30(t *testing.T) {
walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
Total: 1,
Transactions: []*walletv1.WalletTransaction{{
EntryId: 7,
TransactionId: "wtx-coin-1",
BizType: "coin_seller_transfer",
AssetType: "COIN",
AvailableDelta: 300,
AvailableAfter: 1300,
CreatedAtMs: 1_700_000_000_000,
}},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-transactions?page=2", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-coin-ledger")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.lastTransactions == nil || walletClient.lastTransactions.GetUserId() != 42 || walletClient.lastTransactions.GetAssetType() != "COIN" {
t.Fatalf("wallet transaction request mismatch: %+v", walletClient.lastTransactions)
}
if walletClient.lastTransactions.GetPage() != 2 || walletClient.lastTransactions.GetPageSize() != 30 {
t.Fatalf("coin transaction pagination mismatch: %+v", walletClient.lastTransactions)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
items, itemsOK := data["items"].([]any)
if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 || data["page_size"] != float64(30) {
t.Fatalf("coin transaction response mismatch: %+v", response)
}
}
func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) {
walletClient := &fakeWalletClient{rechargeProductsResp: &walletv1.ListRechargeProductsResponse{
Channels: []string{"google"},
Products: []*walletv1.RechargeProduct{{
ProductId: 11,
ProductCode: "iap_android_11",
ProductName: "1500 Coins",
Description: "coin pack",
Platform: "android",
Channel: "google",
CurrencyCode: "USDT",
CoinAmount: 1500,
AmountMicro: 1500000,
AmountMinor: 1500000,
RegionIds: []int64{2002},
ResourceAssetType: "COIN",
Status: "active",
Enabled: true,
}},
}}
profileClient := &fakeUserProfileClient{regionID: 2002}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/recharge/products", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-recharge-products")
request.Header.Set("X-App-Platform", "android")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet)
}
if walletClient.lastRechargeProducts == nil ||
walletClient.lastRechargeProducts.GetUserId() != 42 ||
walletClient.lastRechargeProducts.GetRegionId() != 2002 ||
walletClient.lastRechargeProducts.GetPlatform() != "android" ||
walletClient.lastRechargeProducts.GetAppCode() != "lalu" ||
walletClient.lastRechargeProducts.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-recharge-products") {
t.Fatalf("recharge products request mismatch: %+v", walletClient.lastRechargeProducts)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
products, productsOK := data["products"].([]any)
channels, channelsOK := data["channels"].([]any)
if response.Code != httpkit.CodeOK || !ok || !productsOK || len(products) != 1 || !channelsOK || len(channels) != 1 {
t.Fatalf("recharge response shape mismatch: %+v", response)
}
product, ok := products[0].(map[string]any)
if !ok ||
product["product_name"] != "1500 Coins" ||
product["platform"] != "android" ||
product["channel"] != "google" ||
product["currency_code"] != "USDT" ||
product["coin_amount"] != float64(1500) ||
product["amount_micro"] != float64(1500000) ||
product["resource_asset_type"] != "COIN" ||
product["enabled"] != true {
t.Fatalf("recharge product dto mismatch: %+v", product)
}
}
func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller",

View File

@ -895,25 +895,6 @@ func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request
write(writer, request, resp, err)
}
// transferRoomHost 把主持转移 HTTP JSON 请求转换为 room-service TransferRoomHost 命令。
func (h *Handler) transferRoomHost(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID int64 `json:"target_user_id"`
}
if !decode(writer, request, &body) {
return
}
resp, err := h.roomClient.TransferRoomHost(request.Context(), &roomv1.TransferRoomHostRequest{
Meta: meta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
})
write(writer, request, resp, err)
}
// muteUser 把禁言 HTTP JSON 请求转换为 room-service MuteUser 命令。
func (h *Handler) muteUser(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -1081,7 +1062,7 @@ func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
}
}
if len(userIDs) == 0 {
userIDs = append(userIDs, snapshot.GetHostUserId())
userIDs = append(userIDs, snapshot.GetOwnerUserId())
}
return uniquePositiveUserIDs(userIDs)
}

View File

@ -25,7 +25,6 @@ type roomListItemData struct {
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
OwnerUserID string `json:"owner_user_id,omitempty"`
HostUserID string `json:"host_user_id,omitempty"`
Title string `json:"title,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Mode string `json:"mode,omitempty"`
@ -81,7 +80,6 @@ type roomOnlineUserData struct {
type roomPermissionsData struct {
IsOwner bool `json:"is_owner"`
IsHost bool `json:"is_host"`
IsAdmin bool `json:"is_admin"`
IsMuted bool `json:"is_muted"`
CanSpeak bool `json:"can_speak"`
@ -156,7 +154,6 @@ type roomInitialData struct {
CoverURL string `json:"cover_url,omitempty"`
Description string `json:"description,omitempty"`
OwnerUserID string `json:"owner_user_id,omitempty"`
HostUserID string `json:"host_user_id,omitempty"`
Mode string `json:"mode,omitempty"`
Status string `json:"status,omitempty"`
ChatEnabled bool `json:"chat_enabled"`
@ -243,7 +240,6 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
RoomID: roomID,
IMGroupID: roomIMGroupID(roomID),
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
HostUserID: formatOptionalUserID(room.GetHostUserId()),
Title: room.GetTitle(),
CoverURL: room.GetCoverUrl(),
Mode: room.GetMode(),
@ -316,7 +312,6 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
CoverURL: ext["cover_url"],
Description: ext["description"],
OwnerUserID: formatOptionalUserID(snapshot.GetOwnerUserId()),
HostUserID: formatOptionalUserID(snapshot.GetHostUserId()),
Mode: snapshot.GetMode(),
Status: snapshot.GetStatus(),
ChatEnabled: snapshot.GetChatEnabled(),
@ -385,19 +380,17 @@ func roomPermissionsFromSnapshot(snapshot *roomv1.RoomSnapshot, viewerUserID int
return roomPermissionsData{}
}
isOwner := snapshot.GetOwnerUserId() == viewerUserID
isHost := snapshot.GetHostUserId() == viewerUserID
isAdmin := containsInt64(snapshot.GetAdminUserIds(), viewerUserID)
isMuted := containsInt64(snapshot.GetMuteUserIds(), viewerUserID)
inRoom := protoRoomUserByID(snapshot, viewerUserID) != nil
return roomPermissionsData{
IsOwner: isOwner,
IsHost: isHost,
IsAdmin: isAdmin,
IsMuted: isMuted,
CanSpeak: inRoom && snapshot.GetChatEnabled() && !isMuted,
CanSendGift: inRoom && snapshot.GetStatus() == "active",
CanMicUp: inRoom && snapshot.GetStatus() == "active" && protoSeatByUserID(snapshot, viewerUserID) == nil,
CanManageRoom: inRoom && (isOwner || isHost || isAdmin),
CanManageRoom: inRoom && (isOwner || isAdmin),
CanLeaveRoom: inRoom,
CanCloseRoom: inRoom,
CanMuteMic: inRoom && protoSeatByUserID(snapshot, viewerUserID) != nil,
@ -434,7 +427,6 @@ func roomInitialProfileUserIDs(snapshot *roomv1.RoomSnapshot, viewerUserID int64
}
add(snapshot.GetOwnerUserId())
add(snapshot.GetHostUserId())
add(viewerUserID)
for _, seat := range snapshot.GetMicSeats() {
add(seat.GetUserId())

View File

@ -30,6 +30,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
GetAppBootstrap: h.getAppBootstrap,
ListH5Links: h.listH5Links,
ListAppBanners: h.listAppBanners,
GetAppVersion: h.getAppVersion,
ListResources: h.listResources,
GetResourceGroup: h.getResourceGroup,
ListGifts: h.listGifts,
@ -91,7 +92,6 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomAdmin: h.setRoomAdmin,
TransferRoomHost: h.transferRoomHost,
MuteUser: h.muteUser,
KickUser: h.kickUser,
UnbanUser: h.unbanUser,
@ -105,8 +105,9 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListInboxMessages: h.listInboxMessages,
},
Task: httproutes.TaskHandlers{
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
},
Wallet: httproutes.WalletHandlers{
GetWalletOverview: h.getWalletOverview,
@ -114,6 +115,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListRechargeProducts: h.listRechargeProducts,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions,
TransferCoinFromSeller: h.transferCoinFromSeller,
},

View File

@ -28,6 +28,15 @@ tencent_im:
endpoint: "adminapisgp.im.qcloud.com"
group_type: "ChatRoom"
request_timeout: "5s"
tencent_rtc:
# Docker 本地默认关闭真实 TRTC 管理 API开启后封禁会同步调用 RemoveUserByStrRoomId。
enabled: false
sdk_app_id: 0
secret_id: ""
secret_key: ""
region: "ap-guangzhou"
endpoint: "trtc.tencentcloudapi.com"
request_timeout: "5s"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_max_open_conns: 20
mysql_max_idle_conns: 10

View File

@ -35,6 +35,17 @@ tencent_im:
group_type: "ChatRoom"
# outbox worker 单次公网调用超时。
request_timeout: "5s"
tencent_rtc:
# 线上开启后,平台封禁会同步把用户从 TRTC 字符串房间踢出。
enabled: true
# 必须和 gateway-service tencent_rtc.sdk_app_id 使用同一个 TRTC 应用。
sdk_app_id: 1400000000
# 腾讯云 API 3.0 访问密钥,只允许来自线上密钥系统或私有配置。
secret_id: "TENCENT_CLOUD_SECRET_ID"
secret_key: "TENCENT_CLOUD_SECRET_KEY"
region: "ap-guangzhou"
endpoint: "trtc.tencentcloudapi.com"
request_timeout: "5s"
mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
mysql_max_open_conns: 80
mysql_max_idle_conns: 20

View File

@ -31,6 +31,15 @@ tencent_im:
group_type: "ChatRoom"
# outbox worker 建群和发送房间系统消息的单次 REST 超时。
request_timeout: "5s"
tencent_rtc:
# 本地默认关闭真实 TRTC 管理 API开启后封禁会同步调用 RemoveUserByStrRoomId。
enabled: false
sdk_app_id: 0
secret_id: ""
secret_key: ""
region: "ap-guangzhou"
endpoint: "trtc.tencentcloudapi.com"
request_timeout: "5s"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_max_open_conns: 20
mysql_max_idle_conns: 10

View File

@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS rooms (
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
owner_user_id BIGINT NOT NULL,
host_user_id BIGINT NOT NULL,
seat_count INT NOT NULL,
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
@ -30,7 +29,6 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
visible_region_id BIGINT NOT NULL DEFAULT 0,
owner_user_id BIGINT NOT NULL,
host_user_id BIGINT NOT NULL,
title VARCHAR(128) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
mode VARCHAR(64) NOT NULL,

View File

@ -19,6 +19,7 @@ import (
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc"
"hyapp/services/room-service/internal/config"
"hyapp/services/room-service/internal/healthcheck"
"hyapp/services/room-service/internal/integration"
@ -115,6 +116,7 @@ func New(cfg config.Config) (*App, error) {
roomPublisher := integration.NewNoopRoomEventPublisher()
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
outboxPublishers := []integration.OutboxPublisher{activityPublisher}
var rtcUserRemover integration.RTCUserRemover
if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -129,6 +131,17 @@ func New(cfg config.Config) (*App, error) {
roomPublisher = tencentPublisher
outboxPublishers = append(outboxPublishers, tencentPublisher)
}
if cfg.TencentRTC.Enabled {
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
if err != nil {
_ = repository.Close()
_ = activityConn.Close()
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
rtcUserRemover = rtcClient
}
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
// 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。
@ -139,6 +152,7 @@ func New(cfg config.Config) (*App, error) {
SnapshotEveryN: cfg.SnapshotEveryN,
PresenceStaleAfter: cfg.PresenceStaleAfter,
MicPublishTimeout: cfg.MicPublishTimeout,
RTCUserRemover: rtcUserRemover,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))

View File

@ -11,6 +11,7 @@ import (
"hyapp/pkg/configx"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc"
)
// Config 描述 room-service 进程启动所需的最小配置。
@ -45,6 +46,8 @@ type Config struct {
ActivityServiceAddr string `yaml:"activity_service_addr"`
// TencentIM 是腾讯云 IM 群组和房间系统消息的外部集成配置。
TencentIM TencentIMConfig `yaml:"tencent_im"`
// TencentRTC 是平台级封禁后移出实时音频房的外部集成配置。
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
// MySQLDSN 是 rooms、snapshot、command log 和 outbox 的持久化连接串。
MySQLDSN string `yaml:"mysql_dsn"`
// MySQLMaxOpenConns 控制 MySQL 最大打开连接数。
@ -113,6 +116,40 @@ type TencentIMConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// TencentRTCConfig 描述 room-service 调用腾讯云 TRTC 房间管理 API 的配置。
type TencentRTCConfig struct {
// Enabled 控制是否启用服务端 RTC 踢人;关闭时仍会提交 Room Cell 驱逐。
Enabled bool `yaml:"enabled"`
// SDKAppID 是 TRTC 控制台分配的应用 ID必须和 gateway 签发 RTC UserSig 的应用一致。
SDKAppID int64 `yaml:"sdk_app_id"`
// SecretID/SecretKey 是腾讯云 API 3.0 访问密钥,只能写在线上密钥系统或私有配置中。
SecretID string `yaml:"secret_id"`
SecretKey string `yaml:"secret_key"`
// Region 是腾讯云 API 公共参数TRTC 房间管理接口当前使用 ap-guangzhou/ap-beijing。
Region string `yaml:"region"`
// Endpoint 是腾讯云 API 域名,默认 trtc.tencentcloudapi.com。
Endpoint string `yaml:"endpoint"`
// RequestTimeout 是单次公网调用超时。
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// RESTConfig 把 YAML 配置转换成 pkg/tencentrtc 的 REST 客户端配置。
func (c TencentRTCConfig) RESTConfig() tencentrtc.RESTConfig {
timeout := c.RequestTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return tencentrtc.RESTConfig{
Enabled: c.Enabled,
SDKAppID: c.SDKAppID,
SecretID: c.SecretID,
SecretKey: c.SecretKey,
Region: c.Region,
Endpoint: c.Endpoint,
HTTPClient: &http.Client{Timeout: timeout},
}
}
// RESTConfig 把 YAML 配置转换成 pkg/tencentim 的 REST 客户端配置。
func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
timeout := c.RequestTimeout
@ -157,6 +194,12 @@ func Default() Config {
GroupType: tencentim.DefaultGroupType,
RequestTimeout: 5 * time.Second,
},
TencentRTC: TencentRTCConfig{
Enabled: false,
Region: tencentrtc.DefaultRESTRegion,
Endpoint: tencentrtc.DefaultRESTEndpoint,
RequestTimeout: 5 * time.Second,
},
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
MySQLMaxOpenConns: 20,
MySQLMaxIdleConns: 10,
@ -240,6 +283,19 @@ func Normalize(cfg Config) (Config, error) {
if cfg.ActivityServiceAddr == "" {
cfg.ActivityServiceAddr = "127.0.0.1:13006"
}
cfg.TencentRTC.SecretID = strings.TrimSpace(cfg.TencentRTC.SecretID)
cfg.TencentRTC.SecretKey = strings.TrimSpace(cfg.TencentRTC.SecretKey)
cfg.TencentRTC.Region = strings.TrimSpace(cfg.TencentRTC.Region)
if cfg.TencentRTC.Region == "" {
cfg.TencentRTC.Region = tencentrtc.DefaultRESTRegion
}
cfg.TencentRTC.Endpoint = strings.TrimSpace(cfg.TencentRTC.Endpoint)
if cfg.TencentRTC.Endpoint == "" {
cfg.TencentRTC.Endpoint = tencentrtc.DefaultRESTEndpoint
}
if cfg.TencentRTC.RequestTimeout <= 0 {
cfg.TencentRTC.RequestTimeout = 5 * time.Second
}
outboxWorker, err := normalizeOutboxWorkerConfig(cfg.OutboxWorker)
if err != nil {
return Config{}, err

View File

@ -23,6 +23,12 @@ type RoomEventPublisher interface {
PublishRoomEvent(ctx context.Context, event tencentim.RoomEvent) error
}
// RTCUserRemover 抽象 room-service 对腾讯 RTC 实时房间的服务端踢人能力。
// 连接态仍在腾讯 RTCRoom Cell 只在状态提交后触发这个外部副作用。
type RTCUserRemover interface {
RemoveUserByStrRoomID(ctx context.Context, roomID string, userID int64) error
}
// OutboxPublisher 抽象 outbox worker 对外部消费者的异步投递。
type OutboxPublisher interface {
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。

View File

@ -195,19 +195,6 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
base.TargetUserID = body.GetTargetUserId()
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
return base, true, nil
case "RoomHostTransferred":
// 主持人转移同时携带新旧 host便于客户端展示交接提示。
var body roomeventsv1.RoomHostTransferred
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.TargetUserID = body.GetNewHostUserId()
base.Attributes = map[string]string{
"old_host_user_id": fmt.Sprintf("%d", body.GetOldHostUserId()),
"new_host_user_id": fmt.Sprintf("%d", body.GetNewHostUserId()),
}
return base, true, nil
case "RoomUserMuted":
// 禁言事件只影响公屏发言权限,不改变 presence、麦位或收消息资格。
var body roomeventsv1.RoomUserMuted
@ -274,8 +261,6 @@ func eventTypeForClient(eventType string) string {
return "room_chat_enabled_changed"
case "RoomAdminChanged":
return "room_admin_changed"
case "RoomHostTransferred":
return "room_host_transferred"
case "RoomUserMuted":
return "room_user_muted"
case "RoomUserKicked":

View File

@ -16,7 +16,6 @@ import (
func TestRoomEventFromEnvelopeSkipsNonIMEvents(t *testing.T) {
record, err := outbox.Build("room-noop", "RoomCreated", 1, time.Now(), &roomeventsv1.RoomCreated{
OwnerUserId: 1,
HostUserId: 1,
SeatCount: 4,
Mode: "social",
})

View File

@ -58,8 +58,6 @@ type CreateRoom struct {
Base
// OwnerUserID 是房间所有者,创建后自动进入管理员集合。
OwnerUserID int64 `json:"owner_user_id"`
// HostUserID 是主持人,创建后也进入管理员集合。
HostUserID int64 `json:"host_user_id"`
// SeatCount 决定初始化麦位数量。
SeatCount int32 `json:"seat_count"`
// Mode 记录房间模式,当前版本只保存不做复杂策略分发。
@ -255,16 +253,6 @@ type SetRoomAdmin struct {
// Type 返回命令类型。
func (SetRoomAdmin) Type() string { return "set_room_admin" }
// TransferRoomHost 定义主持人转移请求。
type TransferRoomHost struct {
Base
// TargetUserID 是新的主持人,必须在当前房间 presence 中。
TargetUserID int64 `json:"target_user_id"`
}
// Type 返回命令类型。
func (TransferRoomHost) Type() string { return "transfer_room_host" }
// MuteUser 定义禁言请求。
type MuteUser struct {
Base
@ -287,6 +275,22 @@ type KickUser struct {
// Type 返回命令类型。
func (KickUser) Type() string { return "kick_user" }
// SystemEvictUser 定义平台级封禁、风控或后台治理触发的房间驱逐请求。
type SystemEvictUser struct {
Base
// TargetUserID 是被系统驱逐的房间用户。
TargetUserID int64 `json:"target_user_id"`
// OperatorUserID 是触发治理动作的后台管理员或 App 管理员,不参与房间权限判断。
OperatorUserID int64 `json:"operator_user_id"`
// Reason 记录封禁、风控或后台动作来源,进入 command log 便于审计追踪。
Reason string `json:"reason"`
// BanFromRoom 控制是否写入当前房间 ban 集合。
BanFromRoom bool `json:"ban_from_room"`
}
// Type 返回命令类型。
func (SystemEvictUser) Type() string { return "system_evict_user" }
// UnbanUser 定义解除房间 ban 请求。
type UnbanUser struct {
Base
@ -398,12 +402,12 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &SetChatEnabled{}
case SetRoomAdmin{}.Type():
cmd = &SetRoomAdmin{}
case TransferRoomHost{}.Type():
cmd = &TransferRoomHost{}
case MuteUser{}.Type():
cmd = &MuteUser{}
case KickUser{}.Type():
cmd = &KickUser{}
case SystemEvictUser{}.Type():
cmd = &SystemEvictUser{}
case UnbanUser{}.Type():
cmd = &UnbanUser{}
case SendGift{}.Type():

View File

@ -11,23 +11,19 @@ type roomRole string
const (
// roleOwner 来自 RoomState.OwnerUserID拥有房间内最高管理权限。
roleOwner roomRole = "owner"
// roleHost 来自 RoomState.HostUserID负责现场管理但不能越过 owner。
roleHost roomRole = "host"
// roleAdmin 来自 RoomState.AdminUsers权限低于 owner/host。
// roleAdmin 来自 RoomState.AdminUsers权限低于 owner。
roleAdmin roomRole = "admin"
// roleAudience 是默认兜底角色,包含未登录、未在房间和普通观众。
roleAudience roomRole = "audience"
)
func actorRole(current *state.RoomState, actorUserID int64) roomRole {
// 角色权威顺序按文档固定owner > host > admin > audience不能只看 RoomUser.role 展示字段。
// 角色权威顺序按当前房间状态固定owner > admin > audience不能只看 RoomUser.role 展示字段。
switch {
case current == nil || actorUserID <= 0:
return roleAudience
case actorUserID == current.OwnerUserID:
return roleOwner
case actorUserID == current.HostUserID:
return roleHost
case current.AdminUsers[actorUserID]:
return roleAdmin
default:
@ -44,7 +40,7 @@ func requireActiveRoom(current *state.RoomState) error {
}
func requireActorPresent(current *state.RoomState, actorUserID int64) error {
// 管理动作必须来自房间业务 presence离房后的 host/admin 不能远程管理房间。
// 管理动作必须来自房间业务 presence离房后的 admin 不能远程管理房间。
if _, exists := current.OnlineUsers[actorUserID]; !exists {
return xerr.New(xerr.PermissionDenied, "permission denied")
}
@ -63,8 +59,8 @@ func requireManagerPresent(current *state.RoomState, actorUserID int64) error {
}
func isManagerRole(role roomRole) bool {
// 管理角色集合只包含 owner/host/admin普通观众只能做自助动作。
return role == roleOwner || role == roleHost || role == roleAdmin
// 管理角色集合只包含 owner/admin普通观众只能做自助动作。
return role == roleOwner || role == roleAdmin
}
func canManageTarget(current *state.RoomState, actorUserID int64, targetUserID int64) error {
@ -75,11 +71,7 @@ func canManageTarget(current *state.RoomState, actorUserID int64, targetUserID i
return xerr.New(xerr.PermissionDenied, "permission denied")
}
if target == roleOwner && actor != roleOwner {
// host/admin 不能管理 ownerowner 自操作由具体命令的 self check 再收紧。
return xerr.New(xerr.PermissionDenied, "permission denied")
}
if target == roleHost && actor == roleAdmin {
// 普通 admin 不能管理 hosthost 变更必须走 owner 的 TransferRoomHost。
// admin 不能管理 ownerowner 自操作由具体命令的 self check 再收紧。
return xerr.New(xerr.PermissionDenied, "permission denied")
}
return nil
@ -103,15 +95,3 @@ func requireOwnerPresent(current *state.RoomState, actorUserID int64) error {
}
return nil
}
func requireOwnerOrHostPresent(current *state.RoomState, actorUserID int64) error {
// 解封等高风险操作限制在 owner/host普通 admin 不能恢复被 ban 用户。
if err := requireActorPresent(current, actorUserID); err != nil {
return err
}
role := actorRole(current, actorUserID)
if role != roleOwner && role != roleHost {
return xerr.New(xerr.PermissionDenied, "permission denied")
}
return nil
}

View File

@ -80,7 +80,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
}
actorUserID := req.GetMeta().GetActorUserId()
if actorUserID == 0 {
// CreateRoom 的 owner/host 默认来自已鉴权调用方,不允许匿名内部命令造房间。
// CreateRoom 的 owner 来自已鉴权调用方,不允许匿名内部命令造房间。
return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
}
if s.isDraining() {
@ -91,7 +91,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
cmd := command.CreateRoom{
Base: baseFromMeta(req.GetMeta()),
OwnerUserID: actorUserID,
HostUserID: actorUserID,
SeatCount: seatCount,
Mode: mode,
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
@ -147,7 +146,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
if existing, exists, err := s.repository.GetRoomMetaByOwner(ctx, cmd.OwnerUserID); err != nil {
return nil, err
} else if exists {
// 一个用户只能创建一个房间;加入、主持或管理别人的房间不受这个 owner 限制影响。
// 一个用户只能创建一个房间;加入或管理别人的房间不受这个 owner 限制影响。
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("owner already has room: %s", existing.RoomID))
}
@ -157,7 +156,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
// 创建态直接初始化 RoomStateowner 作为首个在线用户进入房间。
now := s.clock.Now()
roomState := state.NewRoomState(cmd.RoomID(), cmd.OwnerUserID, cmd.HostUserID, cmd.SeatCount, cmd.Mode)
roomState := state.NewRoomState(cmd.RoomID(), cmd.OwnerUserID, cmd.SeatCount, cmd.Mode)
roomState.Version = 1
roomState.VisibleRegionID = cmd.VisibleRegionID
applyRoomProfileExt(roomState, roomProfileInput{
@ -176,7 +175,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
// RoomCreated 进入 outbox供 activity/audit 等房间外系统异步消费。
createdEvent, err := outbox.Build(cmd.RoomID(), "RoomCreated", roomState.Version, now, &roomeventsv1.RoomCreated{
OwnerUserId: cmd.OwnerUserID,
HostUserId: cmd.HostUserID,
SeatCount: cmd.SeatCount,
Mode: cmd.Mode,
RoomName: cmd.RoomName,
@ -193,7 +191,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
AppCode: cmd.AppCode,
RoomID: cmd.RoomID(),
OwnerUserID: cmd.OwnerUserID,
HostUserID: cmd.HostUserID,
SeatCount: cmd.SeatCount,
Mode: cmd.Mode,
Status: state.RoomStatusActive,

Some files were not shown because too many files have changed in this diff Show More