增加注册奖励,金币流水,内购管理
This commit is contained in:
parent
1e542ba563
commit
f7e182844d
@ -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
@ -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);
|
||||
}
|
||||
|
||||
@ -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 ®istrationRewardServiceClient{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",
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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 区分 admin、app_user、system,避免不同身份体系共用一个整数 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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -153,6 +153,7 @@ VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`。
|
||||
资源组继续复用现有资源组能力:
|
||||
|
||||
- 头像框:`avatar_frame`
|
||||
- 资料卡:`profile_card`
|
||||
- 座驾:`vehicle`
|
||||
- 聊天气泡:`chat_bubble`
|
||||
- 徽章:`badge`
|
||||
|
||||
125
docs/flutter对接/app配置与内购对接.md
Normal file
125
docs/flutter对接/app配置与内购对接.md
Normal 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 Billing;iOS 使用 `channel=apple` 的商品对接 StoreKit。
|
||||
137
docs/flutter对接/金币流水对接.md
Normal file
137
docs/flutter对接/金币流水对接.md
Normal 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` 记录到客户端日志。
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
124
docs/注册奖励Flutter对接.md
Normal file
124
docs/注册奖励Flutter对接.md
Normal 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 日期返回,不使用设备时区;客户端只做展示,不要用本地日期自行判断每日份数。
|
||||
@ -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` 表,避免污染用户主数据。
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
211
pkg/tencentrtc/rest_client.go
Normal file
211
pkg/tencentrtc/rest_client.go
Normal 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"`
|
||||
}
|
||||
63
pkg/tencentrtc/rest_client_test.go
Normal file
63
pkg/tencentrtc/rest_client_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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` | 房间解封用户 |
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
301
server/admin/internal/modules/appuser/ban_list.go
Normal file
301
server/admin/internal/modules/appuser/ban_list.go
Normal 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), ",")
|
||||
}
|
||||
61
server/admin/internal/modules/appuser/ban_list_test.go
Normal file
61
server/admin/internal/modules/appuser/ban_list_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
23
server/admin/internal/modules/coinledger/dto.go
Normal file
23
server/admin/internal/modules/coinledger/dto.go
Normal 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"`
|
||||
}
|
||||
80
server/admin/internal/modules/coinledger/handler.go
Normal file
80
server/admin/internal/modules/coinledger/handler.go
Normal 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 ""
|
||||
}
|
||||
9
server/admin/internal/modules/coinledger/request.go
Normal file
9
server/admin/internal/modules/coinledger/request.go
Normal file
@ -0,0 +1,9 @@
|
||||
package coinledger
|
||||
|
||||
type listQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
UserKeyword string
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
15
server/admin/internal/modules/coinledger/routes.go
Normal file
15
server/admin/internal/modules/coinledger/routes.go
Normal 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)
|
||||
}
|
||||
294
server/admin/internal/modules/coinledger/service.go
Normal file
294
server/admin/internal/modules/coinledger/service.go
Normal 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
|
||||
}
|
||||
30
server/admin/internal/modules/coinledger/service_test.go
Normal file
30
server/admin/internal/modules/coinledger/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
305
server/admin/internal/modules/registrationreward/handler.go
Normal file
305
server/admin/internal/modules/registrationreward/handler.go
Normal 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
|
||||
}
|
||||
17
server/admin/internal/modules/registrationreward/routes.go
Normal file
17
server/admin/internal/modules/registrationreward/routes.go
Normal 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)
|
||||
}
|
||||
@ -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{}
|
||||
|
||||
@ -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()})
|
||||
}
|
||||
|
||||
|
||||
172
server/admin/internal/modules/resource/operator.go
Normal file
172
server/admin/internal/modules/resource/operator.go
Normal 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
|
||||
}
|
||||
23
server/admin/internal/modules/resource/operator_test.go
Normal file
23
server/admin/internal/modules/resource/operator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 "":
|
||||
|
||||
41
server/admin/internal/modules/roomadmin/service_test.go
Normal file
41
server/admin/internal/modules/roomadmin/service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -66,6 +66,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.Menu{},
|
||||
&model.AppConfig{},
|
||||
&model.AppBanner{},
|
||||
&model.AppVersion{},
|
||||
&model.RefreshToken{},
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
14
server/admin/migrations/007_admin_app_versions.sql
Normal file
14
server/admin/migrations/007_admin_app_versions.sql
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 替代自研 IM;room-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")))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -23,6 +23,12 @@ type RoomEventPublisher interface {
|
||||
PublishRoomEvent(ctx context.Context, event tencentim.RoomEvent) error
|
||||
}
|
||||
|
||||
// RTCUserRemover 抽象 room-service 对腾讯 RTC 实时房间的服务端踢人能力。
|
||||
// 连接态仍在腾讯 RTC,Room 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 状态重试。
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
})
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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 不能管理 owner,owner 自操作由具体命令的 self check 再收紧。
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
if target == roleHost && actor == roleAdmin {
|
||||
// 普通 admin 不能管理 host;host 变更必须走 owner 的 TransferRoomHost。
|
||||
// admin 不能管理 owner,owner 自操作由具体命令的 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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
// 创建态直接初始化 RoomState,owner 作为首个在线用户进入房间。
|
||||
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
Loading…
x
Reference in New Issue
Block a user