Merge remote-tracking branch 'origin/main' into test

This commit is contained in:
zhx 2026-07-14 20:24:59 +08:00
commit d6d8dfccb4
146 changed files with 12756 additions and 6858 deletions

View File

@ -7,6 +7,13 @@
"runtimeArgs": ["--dir", "/Users/hy/Documents/hy/hyapp-admin-platform", "dev", "--port", "7002"], "runtimeArgs": ["--dir", "/Users/hy/Documents/hy/hyapp-admin-platform", "dev", "--port", "7002"],
"port": 7002, "port": 7002,
"autoPort": false "autoPort": false
},
{
"name": "admin-server",
"runtimeExecutable": "go",
"runtimeArgs": ["run", "-C", "server/admin", "./cmd/server"],
"port": 13100,
"autoPort": false
} }
] ]
} }

File diff suppressed because it is too large Load Diff

View File

@ -1637,6 +1637,21 @@ message UpsertLevelTierResponse {
bool created = 2; bool created = 2;
} }
// BatchUpsertLevelConfigRequest
// rules/tiers DTO使
message BatchUpsertLevelConfigRequest {
RequestMeta meta = 1;
repeated LevelRule rules = 2;
repeated LevelTier tiers = 3;
int64 operator_admin_id = 4;
}
message BatchUpsertLevelConfigResponse {
repeated LevelRule rules = 1;
repeated LevelTier tiers = 2;
int64 server_time_ms = 3;
}
// ListLevelConfigRequest // ListLevelConfigRequest
message ListLevelConfigRequest { message ListLevelConfigRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
@ -2586,6 +2601,7 @@ service AdminGrowthLevelService {
rpc UpsertLevelTrack(UpsertLevelTrackRequest) returns (UpsertLevelTrackResponse); rpc UpsertLevelTrack(UpsertLevelTrackRequest) returns (UpsertLevelTrackResponse);
rpc UpsertLevelRule(UpsertLevelRuleRequest) returns (UpsertLevelRuleResponse); rpc UpsertLevelRule(UpsertLevelRuleRequest) returns (UpsertLevelRuleResponse);
rpc UpsertLevelTier(UpsertLevelTierRequest) returns (UpsertLevelTierResponse); rpc UpsertLevelTier(UpsertLevelTierRequest) returns (UpsertLevelTierResponse);
rpc BatchUpsertLevelConfig(BatchUpsertLevelConfigRequest) returns (BatchUpsertLevelConfigResponse);
rpc BatchGetUserLevelAdminProfiles(BatchGetUserLevelAdminProfilesRequest) returns (BatchGetUserLevelAdminProfilesResponse); rpc BatchGetUserLevelAdminProfiles(BatchGetUserLevelAdminProfilesRequest) returns (BatchGetUserLevelAdminProfilesResponse);
rpc AdjustTemporaryUserLevels(AdjustTemporaryUserLevelsRequest) returns (AdjustTemporaryUserLevelsResponse); rpc AdjustTemporaryUserLevels(AdjustTemporaryUserLevelsRequest) returns (AdjustTemporaryUserLevelsResponse);
} }

View File

@ -5888,6 +5888,7 @@ const (
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack" AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule" AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier" AdminGrowthLevelService_UpsertLevelTier_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTier"
AdminGrowthLevelService_BatchUpsertLevelConfig_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchUpsertLevelConfig"
AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchGetUserLevelAdminProfiles" AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/BatchGetUserLevelAdminProfiles"
AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/AdjustTemporaryUserLevels" AdminGrowthLevelService_AdjustTemporaryUserLevels_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/AdjustTemporaryUserLevels"
) )
@ -5902,6 +5903,7 @@ type AdminGrowthLevelServiceClient interface {
UpsertLevelTrack(ctx context.Context, in *UpsertLevelTrackRequest, opts ...grpc.CallOption) (*UpsertLevelTrackResponse, error) UpsertLevelTrack(ctx context.Context, in *UpsertLevelTrackRequest, opts ...grpc.CallOption) (*UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, in *UpsertLevelRuleRequest, opts ...grpc.CallOption) (*UpsertLevelRuleResponse, error) UpsertLevelRule(ctx context.Context, in *UpsertLevelRuleRequest, opts ...grpc.CallOption) (*UpsertLevelRuleResponse, error)
UpsertLevelTier(ctx context.Context, in *UpsertLevelTierRequest, opts ...grpc.CallOption) (*UpsertLevelTierResponse, error) UpsertLevelTier(ctx context.Context, in *UpsertLevelTierRequest, opts ...grpc.CallOption) (*UpsertLevelTierResponse, error)
BatchUpsertLevelConfig(ctx context.Context, in *BatchUpsertLevelConfigRequest, opts ...grpc.CallOption) (*BatchUpsertLevelConfigResponse, error)
BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error) BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error) AdjustTemporaryUserLevels(ctx context.Context, in *AdjustTemporaryUserLevelsRequest, opts ...grpc.CallOption) (*AdjustTemporaryUserLevelsResponse, error)
} }
@ -5954,6 +5956,16 @@ func (c *adminGrowthLevelServiceClient) UpsertLevelTier(ctx context.Context, in
return out, nil return out, nil
} }
func (c *adminGrowthLevelServiceClient) BatchUpsertLevelConfig(ctx context.Context, in *BatchUpsertLevelConfigRequest, opts ...grpc.CallOption) (*BatchUpsertLevelConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchUpsertLevelConfigResponse)
err := c.cc.Invoke(ctx, AdminGrowthLevelService_BatchUpsertLevelConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminGrowthLevelServiceClient) BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error) { func (c *adminGrowthLevelServiceClient) BatchGetUserLevelAdminProfiles(ctx context.Context, in *BatchGetUserLevelAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserLevelAdminProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUserLevelAdminProfilesResponse) out := new(BatchGetUserLevelAdminProfilesResponse)
@ -5984,6 +5996,7 @@ type AdminGrowthLevelServiceServer interface {
UpsertLevelTrack(context.Context, *UpsertLevelTrackRequest) (*UpsertLevelTrackResponse, error) UpsertLevelTrack(context.Context, *UpsertLevelTrackRequest) (*UpsertLevelTrackResponse, error)
UpsertLevelRule(context.Context, *UpsertLevelRuleRequest) (*UpsertLevelRuleResponse, error) UpsertLevelRule(context.Context, *UpsertLevelRuleRequest) (*UpsertLevelRuleResponse, error)
UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error)
BatchUpsertLevelConfig(context.Context, *BatchUpsertLevelConfigRequest) (*BatchUpsertLevelConfigResponse, error)
BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error) BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error)
AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error) AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error)
mustEmbedUnimplementedAdminGrowthLevelServiceServer() mustEmbedUnimplementedAdminGrowthLevelServiceServer()
@ -6008,6 +6021,9 @@ func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelRule(context.Contex
func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) { func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertLevelTier not implemented") return nil, status.Errorf(codes.Unimplemented, "method UpsertLevelTier not implemented")
} }
func (UnimplementedAdminGrowthLevelServiceServer) BatchUpsertLevelConfig(context.Context, *BatchUpsertLevelConfigRequest) (*BatchUpsertLevelConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchUpsertLevelConfig not implemented")
}
func (UnimplementedAdminGrowthLevelServiceServer) BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error) { func (UnimplementedAdminGrowthLevelServiceServer) BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserLevelAdminProfiles not implemented") return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserLevelAdminProfiles not implemented")
} }
@ -6108,6 +6124,24 @@ func _AdminGrowthLevelService_UpsertLevelTier_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AdminGrowthLevelService_BatchUpsertLevelConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchUpsertLevelConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminGrowthLevelServiceServer).BatchUpsertLevelConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminGrowthLevelService_BatchUpsertLevelConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminGrowthLevelServiceServer).BatchUpsertLevelConfig(ctx, req.(*BatchUpsertLevelConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUserLevelAdminProfilesRequest) in := new(BatchGetUserLevelAdminProfilesRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -6167,6 +6201,10 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpsertLevelTier", MethodName: "UpsertLevelTier",
Handler: _AdminGrowthLevelService_UpsertLevelTier_Handler, Handler: _AdminGrowthLevelService_UpsertLevelTier_Handler,
}, },
{
MethodName: "BatchUpsertLevelConfig",
Handler: _AdminGrowthLevelService_BatchUpsertLevelConfig_Handler,
},
{ {
MethodName: "BatchGetUserLevelAdminProfiles", MethodName: "BatchGetUserLevelAdminProfiles",
Handler: _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler, Handler: _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,8 @@ message GameCatalogItem {
int64 created_at_ms = 15; int64 created_at_ms = 15;
int64 updated_at_ms = 16; int64 updated_at_ms = 16;
int32 safe_height = 17; int32 safe_height = 17;
// whitelist_enabled game_user_whitelist
bool whitelist_enabled = 18;
} }
message AppGame { message AppGame {
@ -682,6 +684,53 @@ message DiceRobotResponse {
int64 server_time_ms = 2; int64 server_time_ms = 2;
} }
// GameWhitelistUser game-service 访 admin-server user-service
message GameWhitelistUser {
string app_code = 1;
string game_id = 2;
int64 user_id = 3;
int64 created_by_admin_id = 4;
int64 created_at_ms = 5;
}
message SetGameWhitelistEnabledRequest {
RequestMeta meta = 1;
string game_id = 2;
bool enabled = 3;
}
message ListGameWhitelistUsersRequest {
RequestMeta meta = 1;
string game_id = 2;
int32 page_size = 3;
}
message ListGameWhitelistUsersResponse {
repeated GameWhitelistUser users = 1;
int64 server_time_ms = 2;
}
message AddGameWhitelistUserRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message GameWhitelistUserResponse {
GameWhitelistUser user = 1;
int64 server_time_ms = 2;
}
message DeleteGameWhitelistUserRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message DeleteGameWhitelistUserResponse {
int64 server_time_ms = 1;
}
service GameAppService { service GameAppService {
rpc ListGames(ListGamesRequest) returns (ListGamesResponse); rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
rpc ListRecentGames(ListRecentGamesRequest) returns (ListGamesResponse); rpc ListRecentGames(ListRecentGamesRequest) returns (ListGamesResponse);
@ -718,6 +767,10 @@ service GameAdminService {
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse); rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse); rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse); rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
rpc SetGameWhitelistEnabled(SetGameWhitelistEnabledRequest) returns (CatalogResponse);
rpc ListGameWhitelistUsers(ListGameWhitelistUsersRequest) returns (ListGameWhitelistUsersResponse);
rpc AddGameWhitelistUser(AddGameWhitelistUserRequest) returns (GameWhitelistUserResponse);
rpc DeleteGameWhitelistUser(DeleteGameWhitelistUserRequest) returns (DeleteGameWhitelistUserResponse);
rpc ListSelfGames(ListSelfGamesRequest) returns (ListSelfGamesResponse); rpc ListSelfGames(ListSelfGamesRequest) returns (ListSelfGamesResponse);
rpc UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse); rpc UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse);
rpc GetSelfGameNewUserPolicy(GetSelfGameNewUserPolicyRequest) returns (SelfGameNewUserPolicyResponse); rpc GetSelfGameNewUserPolicy(GetSelfGameNewUserPolicyRequest) returns (SelfGameNewUserPolicyResponse);

View File

@ -943,6 +943,10 @@ const (
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog" GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus" GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog" GameAdminService_DeleteCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteCatalog"
GameAdminService_SetGameWhitelistEnabled_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameWhitelistEnabled"
GameAdminService_ListGameWhitelistUsers_FullMethodName = "/hyapp.game.v1.GameAdminService/ListGameWhitelistUsers"
GameAdminService_AddGameWhitelistUser_FullMethodName = "/hyapp.game.v1.GameAdminService/AddGameWhitelistUser"
GameAdminService_DeleteGameWhitelistUser_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteGameWhitelistUser"
GameAdminService_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames" GameAdminService_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig" GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
GameAdminService_GetSelfGameNewUserPolicy_FullMethodName = "/hyapp.game.v1.GameAdminService/GetSelfGameNewUserPolicy" GameAdminService_GetSelfGameNewUserPolicy_FullMethodName = "/hyapp.game.v1.GameAdminService/GetSelfGameNewUserPolicy"
@ -972,6 +976,10 @@ type GameAdminServiceClient interface {
UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error) UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error) SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error) DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error)
SetGameWhitelistEnabled(ctx context.Context, in *SetGameWhitelistEnabledRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
ListGameWhitelistUsers(ctx context.Context, in *ListGameWhitelistUsersRequest, opts ...grpc.CallOption) (*ListGameWhitelistUsersResponse, error)
AddGameWhitelistUser(ctx context.Context, in *AddGameWhitelistUserRequest, opts ...grpc.CallOption) (*GameWhitelistUserResponse, error)
DeleteGameWhitelistUser(ctx context.Context, in *DeleteGameWhitelistUserRequest, opts ...grpc.CallOption) (*DeleteGameWhitelistUserResponse, error)
ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error) ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error) UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error)
GetSelfGameNewUserPolicy(ctx context.Context, in *GetSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error) GetSelfGameNewUserPolicy(ctx context.Context, in *GetSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error)
@ -1059,6 +1067,46 @@ func (c *gameAdminServiceClient) DeleteCatalog(ctx context.Context, in *DeleteCa
return out, nil return out, nil
} }
func (c *gameAdminServiceClient) SetGameWhitelistEnabled(ctx context.Context, in *SetGameWhitelistEnabledRequest, opts ...grpc.CallOption) (*CatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CatalogResponse)
err := c.cc.Invoke(ctx, GameAdminService_SetGameWhitelistEnabled_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListGameWhitelistUsers(ctx context.Context, in *ListGameWhitelistUsersRequest, opts ...grpc.CallOption) (*ListGameWhitelistUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListGameWhitelistUsersResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListGameWhitelistUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) AddGameWhitelistUser(ctx context.Context, in *AddGameWhitelistUserRequest, opts ...grpc.CallOption) (*GameWhitelistUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GameWhitelistUserResponse)
err := c.cc.Invoke(ctx, GameAdminService_AddGameWhitelistUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) DeleteGameWhitelistUser(ctx context.Context, in *DeleteGameWhitelistUserRequest, opts ...grpc.CallOption) (*DeleteGameWhitelistUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGameWhitelistUserResponse)
err := c.cc.Invoke(ctx, GameAdminService_DeleteGameWhitelistUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error) { func (c *gameAdminServiceClient) ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSelfGamesResponse) out := new(ListSelfGamesResponse)
@ -1239,6 +1287,10 @@ type GameAdminServiceServer interface {
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error) UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error) SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error)
DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error)
SetGameWhitelistEnabled(context.Context, *SetGameWhitelistEnabledRequest) (*CatalogResponse, error)
ListGameWhitelistUsers(context.Context, *ListGameWhitelistUsersRequest) (*ListGameWhitelistUsersResponse, error)
AddGameWhitelistUser(context.Context, *AddGameWhitelistUserRequest) (*GameWhitelistUserResponse, error)
DeleteGameWhitelistUser(context.Context, *DeleteGameWhitelistUserRequest) (*DeleteGameWhitelistUserResponse, error)
ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error) ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error)
UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error) UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error)
GetSelfGameNewUserPolicy(context.Context, *GetSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error) GetSelfGameNewUserPolicy(context.Context, *GetSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error)
@ -1284,6 +1336,18 @@ func (UnimplementedGameAdminServiceServer) SetGameStatus(context.Context, *SetGa
func (UnimplementedGameAdminServiceServer) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) { func (UnimplementedGameAdminServiceServer) DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteCatalog not implemented") return nil, status.Errorf(codes.Unimplemented, "method DeleteCatalog not implemented")
} }
func (UnimplementedGameAdminServiceServer) SetGameWhitelistEnabled(context.Context, *SetGameWhitelistEnabledRequest) (*CatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetGameWhitelistEnabled not implemented")
}
func (UnimplementedGameAdminServiceServer) ListGameWhitelistUsers(context.Context, *ListGameWhitelistUsersRequest) (*ListGameWhitelistUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListGameWhitelistUsers not implemented")
}
func (UnimplementedGameAdminServiceServer) AddGameWhitelistUser(context.Context, *AddGameWhitelistUserRequest) (*GameWhitelistUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddGameWhitelistUser not implemented")
}
func (UnimplementedGameAdminServiceServer) DeleteGameWhitelistUser(context.Context, *DeleteGameWhitelistUserRequest) (*DeleteGameWhitelistUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteGameWhitelistUser not implemented")
}
func (UnimplementedGameAdminServiceServer) ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error) { func (UnimplementedGameAdminServiceServer) ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListSelfGames not implemented") return nil, status.Errorf(codes.Unimplemented, "method ListSelfGames not implemented")
} }
@ -1464,6 +1528,78 @@ func _GameAdminService_DeleteCatalog_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _GameAdminService_SetGameWhitelistEnabled_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetGameWhitelistEnabledRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).SetGameWhitelistEnabled(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_SetGameWhitelistEnabled_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).SetGameWhitelistEnabled(ctx, req.(*SetGameWhitelistEnabledRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListGameWhitelistUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListGameWhitelistUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListGameWhitelistUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListGameWhitelistUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListGameWhitelistUsers(ctx, req.(*ListGameWhitelistUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_AddGameWhitelistUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddGameWhitelistUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).AddGameWhitelistUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_AddGameWhitelistUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).AddGameWhitelistUser(ctx, req.(*AddGameWhitelistUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_DeleteGameWhitelistUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGameWhitelistUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).DeleteGameWhitelistUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_DeleteGameWhitelistUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).DeleteGameWhitelistUser(ctx, req.(*DeleteGameWhitelistUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListSelfGames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _GameAdminService_ListSelfGames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSelfGamesRequest) in := new(ListSelfGamesRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -1801,6 +1937,22 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteCatalog", MethodName: "DeleteCatalog",
Handler: _GameAdminService_DeleteCatalog_Handler, Handler: _GameAdminService_DeleteCatalog_Handler,
}, },
{
MethodName: "SetGameWhitelistEnabled",
Handler: _GameAdminService_SetGameWhitelistEnabled_Handler,
},
{
MethodName: "ListGameWhitelistUsers",
Handler: _GameAdminService_ListGameWhitelistUsers_Handler,
},
{
MethodName: "AddGameWhitelistUser",
Handler: _GameAdminService_AddGameWhitelistUser_Handler,
},
{
MethodName: "DeleteGameWhitelistUser",
Handler: _GameAdminService_DeleteGameWhitelistUser_Handler,
},
{ {
MethodName: "ListSelfGames", MethodName: "ListSelfGames",
Handler: _GameAdminService_ListSelfGames_Handler, Handler: _GameAdminService_ListSelfGames_Handler,

View File

@ -1,6 +1,6 @@
# Fami VIP Flutter 对接方案 # 可配置 VIP 权限体系 Flutter 对接方案Fami / Lalu
本文只定义 Flutter 与已落地后端的对接契约,不要在客户端重复实现 VIP 状态机。界面和文案统一使用“VIP”不再使用“SVIP”。 本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”不再使用“SVIP”。
## 1. 通用约定 ## 1. 通用约定
@ -21,7 +21,178 @@
- 所有 `*_at_ms` / `duration_ms` 都是 Unix epoch milliseconds。业务切日使用 UTCFlutter 只在展示层转本地时间。 - 所有 `*_at_ms` / `duration_ms` 都是 Unix epoch milliseconds。业务切日使用 UTCFlutter 只在展示层转本地时间。
- 客户端展示当前身份只读 `state.effective_vip`,权益资格只读 `state.effective_benefits`。不要根据等级、`unlock_level` 或 App 名自行继承权益;进房/上线通知还要结合 `state.user_settings` - 客户端展示当前身份只读 `state.effective_vip`,权益资格只读 `state.effective_benefits`。不要根据等级、`unlock_level` 或 App 名自行继承权益;进房/上线通知还要结合 `state.user_settings`
## 2. VIP 套餐 ## 2. 客户端权限控制方案
### 2.1 结论
客户端只把“VIP 等级”用于套餐名称、价格、有效期和等级对比展示;所有功能权限一律使用稳定的 `benefit_code` 判断:
```text
后台等级配置 -> wallet 计算当前 effective_benefits -> Flutter 权益快照 -> 页面预判
-> owner service 最终鉴权
```
权限分三层,三层职责不能混用:
| 层级 | 负责内容 | 是否是最终权限依据 |
|---|---|---|
| VIP 配置与 wallet 快照 | 按当前 App、付费 VIP/体验卡、开关和配置版本计算 `effective_benefits` | 是,提供资格事实 |
| Flutter 权益中心 | 控制入口显隐、锁态、升级引导、装扮渲染,减少无效请求 | 否,只是体验层预判 |
| room/user/wallet/notice 等 owner service | 在写操作发生时重新校验权益、业务状态和平台权限 | 是,接口结果永远优先 |
禁止以下实现:
- `vipLevel >= 8` 就允许进房通知,或 `vipLevel >= 9` 就认为不可踢。
- 根据 `unlock_level` 在客户端累计低等级权益。
- `if (appCode == 'fami')` 写一套权限分支、`lalu` 再写另一套。
- 用本地缓存阻止房主发起踢人/禁言,或用缓存绕过服务端拒绝。
- 收到 VIP IM 后直接拼装权限,不再查询 `/vip/me`
成熟会员/订阅产品通常采用“服务端维护 entitlement当前资格客户端缓存当前资格并做 UI 预判敏感能力由服务端再次鉴权”的模式。Google Play 官方建议在安全后端管理购买状态并在授予权益前验证购买Apple 的 StoreKit 也提供 current entitlements并给出服务端判定服务资格的模型。这里沿用同一边界但资格来源是本项目的 wallet VIP 状态,而不是让 Flutter 直接解释订单:
- [Google Play在安全后端管理购买生命周期与权益](https://developer.android.com/google/play/billing/backend?hl=en)
- [Google Play授予权益前验证购买](https://developer.android.com/google/play/billing/security?hl=en)
- [Apple StoreKitcurrentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements)
- [Apple StoreKit服务端判定 entitlement](https://developer.apple.com/documentation/StoreKit/determining-service-entitlement-on-the-server)
### 2.2 Flutter 统一权益中心
建议建立一个按登录 App 隔离的 `VipEntitlementStore`。下面是数据结构示意,不要求照搬类名:
```dart
class VipEntitlementSnapshot {
final VipProgramConfig program;
final VipIdentity effectiveVip;
final String effectiveSource; // paid / trial / none
final Map<String, VipBenefit> benefitsByCode;
final VipUserSettings settings;
final int evaluatedAtMs;
bool has(String code) {
return program.status == 'active' &&
effectiveVip.active &&
benefitsByCode[code]?.status == 'active';
}
bool allowsRoomEntryNotice() {
return has('room_entry_notice') && settings.roomEntryNoticeEnabled;
}
bool allowsOnlineGlobalNotice() {
return has('online_global_notice') &&
settings.onlineGlobalNoticeEnabled;
}
}
```
`benefitsByCode` 只能由 `/vip/me.data.state.effective_benefits` 建立。客户端的已知权益注册表只描述“这个 code 由哪个模块渲染/调用”,不能配置“最低 VIP 等级”:
```dart
final benefitHandlers = <String, VipBenefitHandler>{
'custom_room_background': roomBackgroundHandler,
'daily_coin_rebate': coinRebateHandler,
'room_entry_notice': roomEntryNoticeHandler,
'online_global_notice': onlineNoticeHandler,
};
```
遇到服务端新下发、旧客户端不认识的 `benefit_code` 时:保留在快照中、上报一次兼容性埋点,但不自动开放未知功能、不崩溃。这样后台可先配置新权益,旧版本客户端会安全忽略。
### 2.3 页面与写操作的统一决策
| 场景 | Flutter 预处理 | 最终处理 |
|---|---|---|
| VIP 中心等级对比 | 使用 `packages[].benefits` 展示每个套餐当前完整权益 | 仅展示,不计算用户权限 |
| 功能入口 | `store.has(code)` 决定正常态/锁态和升级引导 | 点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 `PERMISSION_DENIED` |
| 金币返现 | `daily_coin_rebate` 可用于显示入口,金额/状态不在本地推导 | current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 `/vip/online-notice` | activity/notice 链路异步投递 |
对于锁定入口,“升级到哪一级”也不能使用本地最低等级表。应按 `sort_order` 遍历当前 `/vip/packages`,找到第一个 `status=active``can_purchase=true``benefits` 包含目标 code 的套餐;找不到时只显示“暂未开放”,不要给出错误等级承诺。
## 3. 每一级 VIP 如何展示和控制
### 3.1 Fami P1 初始权益矩阵
下表是初始化配置中的“本级新增权益”,用于产品和客户端理解页面分组,不是客户端授权表。后台保存的是每一级完整权益集合,运营调整后必须以 `packages[level].benefits` 为准。
| 等级 | 初始化新增权益 |
|---|---|
| VIP1 | `vip_badge_identity` VIP标识、`vip_medal` VIP勋章、`avatar_frame` VIP头像框、`vip_title` VIP称号 |
| VIP2 | `chat_bubble` VIP聊天气泡、`vip_gift` VIP礼物、`entry_effect` VIP进场通知、`visitor_history` 查看访客 |
| VIP3 | `voice_wave` VIP声波纹、`profile_card` VIP资料卡皮肤、`custom_room_background` 自定义房间背景、`room_image_message` 房间发图 |
| VIP4 | `animated_avatar` 动态头像、`animated_room_cover` 动态房间封面、`mic_skin` 麦克风皮肤、`room_border` 房间边框、`vehicle` VIP座驾 |
| VIP5 | `colored_room_name` VIP彩色房间名称、`colored_nickname` VIP彩色昵称、`colored_id` VIP彩色ID、`gift_tray_skin` 送礼托盘皮肤 |
| VIP6 | `hide_profile_data` 隐藏个人数据、`custom_avatar_frame` 定制头框、`leaderboard_invisible` 榜单隐身、`daily_coin_rebate` 金币返现 |
| VIP7 | `custom_profile_card` 定制资料卡、`anonymous_profile_visit` 匿名访问主页、`custom_gift` 定制礼物 |
| VIP8 | `room_entry_notice` 进房高亮通知、`custom_pretty_id` 定制靓号、`custom_vehicle` 定制座驾 |
| VIP9 | `anti_kick` 防踢、`anti_mute` 防禁言、`online_global_notice` 上线全服通知 |
展示等级详情时直接循环该套餐的 `benefits`,可按 `benefit_type``execution_scope` 分组;不要把上表写进 Flutter。高等级是否包含低等级权益由后台下发的完整集合决定。
### 3.2 Lalu 与其他 App
- `/vip/packages``/vip/me` 返回当前登录态对应 App 的配置Flutter 不传 `app_code`
- Lalu 当前是 `legacy_timed`Fami 当前是 `tiered_privilege_v1`;客户端只展示 `program_config`,不按该值重写购买状态机。
- 等级数量使用 `program_config.level_count`,可购买等级使用 `packages` 实际数组。不能假设所有 App 都有 VIP1VIP9。
- 套餐是否可买使用 `can_purchase`;购买后的等级、期限、权益和当前展示身份使用购买响应中的 `state`
## 4. 权益快照生命周期与缓存
### 4.1 刷新时机
| 事件 | 客户端动作 |
|---|---|
| 登录成功、切换账号或切换 App | 清空旧 App store调用 `/vip/me` |
| App 冷启动、回前台 | 调 `/vip/me` 校准过期、后台停用和体验卡回落 |
| 进入 VIP 中心 | 并发调用 `/vip/packages``/vip/me` |
| 购买、佩戴体验卡、卸下体验卡成功 | 立即用响应 `state` 原子替换 store |
| 收到 `vip_notice` | 用 `event_id` 去重,然后调用 `/vip/me`;若版本变更再刷新 packages |
| `state.program_config.config_version` 与套餐版本不一致 | 用户权限先以 state 为准,重新请求 packages不能跨版本合并权益数组 |
| 进入房间 | 本人首屏读 Join 响应;其他人的进房效果读当前房间 IM不查询对方 `/vip/me` |
内存快照是当前会话的主缓存。磁盘缓存只可用于会员徽章等首帧占位,必须立刻后台校准;防踢、防禁言、返现、发送图片等敏感功能不能在离线状态仅凭磁盘快照授权。
客户端不要用手机定时器直接删除权益。可以用 `expires_at_ms` 做倒计时和提前显示过期态,但恢复联网或执行写操作时,最终结果仍以服务端为准。
### 4.2 `config_version` 规则
- `/vip/me.state.program_config.config_version`:当前用户权益快照所依据的版本。
- `/vip/packages.program_config.config_version` 和各 package 的 `config_version`:套餐列表所依据的版本。
- 收到更高版本的购买响应或 `vip_notice.config_version` 时,刷新 `/vip/me``/vip/packages`
- 版本不一致时,绝不能把旧 packages 的权益补进新 state也不能把新 packages 当作用户已拥有权益。
## 5. 当前接口与消息总览
### 5.1 HTTP
| 目的 | 方法与地址 | 客户端状态影响 |
|---|---|---|
| 套餐与每级完整权益 | `GET /api/v1/vip/packages` | 更新套餐目录,不直接授权 |
| 当前 VIP 与生效权益 | `GET /api/v1/vip/me` | 原子替换 VIP store |
| 购买/续期/升级 | `POST /api/v1/vip/purchase` | 成功后使用响应 `state` |
| 体验卡背包 | `GET /api/v1/users/me/resources?resource_type=vip_trial_card` | 只更新卡列表 |
| 佩戴体验卡 | `POST /api/v1/vip/trial-cards/{entitlement_id}/equip` | 成功后使用响应 `state` |
| 卸下体验卡 | `DELETE /api/v1/vip/trial-cards/equipped` | 成功后使用响应 `state` |
| VIP 用户开关 | `GET/PATCH /api/v1/vip/settings` | 更新 `user_settings`,不改变权益集 |
| 金币返现 | `/api/v1/vip/coin-rebates/*` | 使用服务端记录和余额回执 |
| 上线全服通知 | `POST /api/v1/vip/online-notice` | 只创建异步播报事件 |
### 5.2 腾讯云 IM 与 inbox
| 事件 | 通道与标识 | 客户端处理 |
|---|---|---|
| 购买/直接激活 VIP | C2C `TIMCustomElem``Ext=vip_notice``Desc=VipActivated` | `event_id` 去重,刷新 `/vip/me`,不能直接授权 |
| 后台发 Fami 体验卡 | 当前无专用 C2C | 进入背包时刷新资源列表,不假装自动佩戴 |
| 用户进入房间 | 当前房间群 `TIMCustomElem``Ext=room_system_message``Desc=room_user_joined` | 使用事件内 VIP 快照渲染本次进房效果 |
| VIP 上线全服通知 | 全局播报群 `TIMCustomElem``Ext=im_broadcast``Desc=vip_online_notice` | `event_id` 去重并渲染事件快照 |
| VIP 金币返现 | 后端 system inbox`action_type=vip_coin_rebate_claim` | 解析 `action_param` 后调用 statuses 恢复真实状态 |
当前没有“任意 VIP 权限变化”的通用 IM。客户端必须依靠上述刷新生命周期校准不能把 IM 是否到达当作权益是否生效的依据。
## 6. VIP 套餐
### 接口地址 ### 接口地址
@ -150,7 +321,7 @@
- `benefits` 已是该等级的完整集合。`unlock_level` 仅是配置来源信息,不参与 Flutter 权益计算。 - `benefits` 已是该等级的完整集合。`unlock_level` 仅是配置来源信息,不参与 Flutter 权益计算。
- `current_vip` 是兼容字段,新代码优先使用 `state.effective_vip` - `current_vip` 是兼容字段,新代码优先使用 `state.effective_vip`
## 3. 当前 VIP 状态 ## 7. 当前 VIP 状态
### 接口地址 ### 接口地址
@ -278,7 +449,7 @@
- 无 VIP 时,`effective_source="none"``effective_vip.active=false``effective_benefits=[]` - 无 VIP 时,`effective_source="none"``effective_vip.active=false``effective_benefits=[]`
- `user_settings.updated_at_ms=0` 表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。 - `user_settings.updated_at_ms=0` 表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
## 4. 购买、续期和升级 ## 8. 购买、续期和升级
### 接口地址 ### 接口地址
@ -381,7 +552,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED` - 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED`
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。 - 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
## 5. 体验卡背包列表 ## 9. 体验卡背包列表
### 接口地址 ### 接口地址
@ -449,7 +620,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 列表仅返回当前有效的背包实例。卡过期后会自然从 active 列表消失。 - 列表仅返回当前有效的背包实例。卡过期后会自然从 active 列表消失。
- 预置卡素材初始为空Flutter 需提供无图兜底,不得拼接本地固定素材 URL。 - 预置卡素材初始为空Flutter 需提供无图兜底,不得拼接本地固定素材 URL。
## 6. 佩戴体验卡 ## 10. 佩戴体验卡
### 接口地址 ### 接口地址
@ -518,7 +689,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 倒计时使用 `expires_at_ms - server_time_ms`,不要在每次切换时重置为 `duration_ms` - 倒计时使用 `expires_at_ms - server_time_ms`,不要在每次切换时重置为 `duration_ms`
- Flutter 的 VIP 流程固定调用本接口,不调用通用 `/users/me/resources/{resource_id}/equip`。后端虽兼容通用背包装备并会内部转交同一 VIP 状态机,但通用响应没有完整 `state`;历史通用背包组件若触发成功,必须立即补拉 `GET /vip/me` - Flutter 的 VIP 流程固定调用本接口,不调用通用 `/users/me/resources/{resource_id}/equip`。后端虽兼容通用背包装备并会内部转交同一 VIP 状态机,但通用响应没有完整 `state`;历史通用背包组件若触发成功,必须立即补拉 `GET /vip/me`
## 7. 卸下体验卡 ## 11. 卸下体验卡
### 接口地址 ### 接口地址
@ -573,7 +744,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- 卸下只删除佩戴关系,不删卡、不暂停卡、不修改卡的截止时间。 - 卸下只删除佩戴关系,不删卡、不暂停卡、不修改卡的截止时间。
- 卸下后优先回落到有效 `paid_vip`;没有付费 VIP 则 `effective_source="none"` - 卸下后优先回落到有效 `paid_vip`;没有付费 VIP 则 `effective_source="none"`
## 8. 经理中心发放 VIP ## 12. 经理中心发放 VIP
经理中心页面如果复用 Flutter仍调用原地址不需要按 Fami/Lalu 写分支。gateway 会读取当前 App 的 `program_config.grant_mode`Lalu 调直接会员发放Fami 调体验卡专用发放。 经理中心页面如果复用 Flutter仍调用原地址不需要按 Fami/Lalu 写分支。gateway 会读取当前 App 的 `program_config.grant_mode`Lalu 调直接会员发放Fami 调体验卡专用发放。
@ -648,7 +819,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- `trial_card` 返回的 `active=false` 表示没有直接激活会员;卡片本身状态看 `card_status` - `trial_card` 返回的 `active=false` 表示没有直接激活会员;卡片本身状态看 `card_status`
- 配置恰好在 gateway 查询后被后台切换时wallet 会拒绝旧模式写入Flutter 展示接口错误并允许使用同一 `command_id` 重试。 - 配置恰好在 gateway 查询后被后台切换时wallet 会拒绝旧模式写入Flutter 展示接口错误并允许使用同一 `command_id` 重试。
## 9. 进房通知 IM ## 13. 进房通知 IM
### 接口地址 ### 接口地址
@ -727,7 +898,7 @@ Fami`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。 - 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。 - 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
## 10. VIP 通知开关 ## 14. VIP 通知开关
### 接口地址 ### 接口地址
@ -809,7 +980,7 @@ PATCH
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。 - 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 设置按 App 隔离Fami 的修改不影响 Lalu。 - 设置按 App 隔离Fami 的修改不影响 Lalu。
## 11. 每日 VIP 金币返现 ## 15. 每日 VIP 金币返现
### 接口地址 ### 接口地址
@ -989,7 +1160,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 领取成功后用 `data.coin_balance` 替换 COIN 余额快照,不在 Flutter 本地做 `balance += coin_amount` - 领取成功后用 `data.coin_balance` 替换 COIN 余额快照,不在 Flutter 本地做 `balance += coin_amount`
- 记录不存在或不属于当前用户时返回 `VIP_COIN_REBATE_NOT_FOUND`;复用一个 `command_id` 改投另一笔返现时返回 `IDEMPOTENCY_CONFLICT` - 记录不存在或不属于当前用户时返回 `VIP_COIN_REBATE_NOT_FOUND`;复用一个 `command_id` 改投另一笔返现时返回 `IDEMPOTENCY_CONFLICT`
## 12. 上线全服通知 ## 16. 上线全服通知
### 接口地址 ### 接口地址
@ -1078,7 +1249,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me` - 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。 - 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
## 13. 权益场景和完成边界 ## 17. 权益场景和完成边界
| 场景 | Flutter 处理 | 当前后端状态 | | 场景 | Flutter 处理 | 当前后端状态 |
|---|---|---| |---|---|---|
@ -1097,7 +1268,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM服务端不做日级去重 | | 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM服务端不做日级去重 |
| 其他 VIP 装扮/功能 | 可根据 `effective_benefits` 展示锁定/解锁态;有素材时使用后端 URL | 权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 `resource_id=0`、素材为空,未绑定 owner/资源前不能宣称已实现 | | 其他 VIP 装扮/功能 | 可根据 `effective_benefits` 展示锁定/解锁态;有素材时使用后端 URL | 权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 `resource_id=0`、素材为空,未绑定 owner/资源前不能宣称已实现 |
## 14. Flutter 建议状态流 ## 18. Flutter 建议状态流
1. 进 VIP 页并发请求 `/vip/packages``/vip/me`,套餐用前者,用户生效状态用后者。 1. 进 VIP 页并发请求 `/vip/packages``/vip/me`,套餐用前者,用户生效状态用后者。
2. 购买成功、佩戴成功、卸下成功时,立即用响应中的 `state` 替换全局 VIP store。 2. 购买成功、佩戴成功、卸下成功时,立即用响应中的 `state` 替换全局 VIP store。

View File

@ -134,7 +134,10 @@ X-App-Code: lalu
"user_id": "10001", "user_id": "10001",
"display_user_id": "888888", "display_user_id": "888888",
"username": "Luna", "username": "Luna",
"avatar": "https://cdn.example/avatar/10001.png" "avatar": "https://cdn.example/avatar/10001.png",
"short_badge_cover_urls": [
"https://cdn.example/badges/short-1.png"
]
} }
} }
], ],
@ -164,6 +167,8 @@ X-App-Code: lalu
} }
``` ```
`user.short_badge_cover_urls` 只包含用户当前佩戴的普通短徽章静态封面,长徽章和等级徽章不会混入。用户没有短徽章时字段省略,客户端按空数组处理即可;房间榜没有用户徽章字段。
房间榜返回示例: 房间榜返回示例:
```json ```json
@ -358,12 +363,14 @@ class LeaderboardUser {
required this.displayUserId, required this.displayUserId,
required this.username, required this.username,
required this.avatar, required this.avatar,
required this.shortBadgeCoverUrls,
}); });
final String userId; final String userId;
final String displayUserId; final String displayUserId;
final String username; final String username;
final String avatar; final String avatar;
final List<String> shortBadgeCoverUrls;
factory LeaderboardUser.fromJson(Map<String, dynamic> json) { factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
return LeaderboardUser( return LeaderboardUser(
@ -371,6 +378,9 @@ class LeaderboardUser {
displayUserId: json['display_user_id'] as String? ?? '', displayUserId: json['display_user_id'] as String? ?? '',
username: json['username'] as String? ?? '', username: json['username'] as String? ?? '',
avatar: json['avatar'] as String? ?? '', avatar: json['avatar'] as String? ?? '',
shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List<dynamic>? ?? const [])
.whereType<String>()
.toList(),
); );
} }
} }

View File

@ -70,6 +70,8 @@ var catalog = map[Code]Spec{
DeviceAlreadyRegistered, DeviceAlreadyRegistered,
"device already registered", "device already registered",
), ),
CPAlreadyExistsSelf: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsSelf, "You already have a CP"),
CPAlreadyExistsOther: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsOther, "The other party already has a CP"),
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"), InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"), DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),

View File

@ -75,6 +75,10 @@ const (
InvalidInviteCode Code = "INVALID_INVITE_CODE" InvalidInviteCode Code = "INVALID_INVITE_CODE"
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。 // DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED" DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED"
// CPAlreadyExistsSelf 表示当前点击同意申请的用户已经有 active CP。
CPAlreadyExistsSelf Code = "CP_ALREADY_EXISTS_SELF"
// CPAlreadyExistsOther 表示申请另一方已经有 active CP。
CPAlreadyExistsOther Code = "CP_ALREADY_EXISTS_OTHER"
// InsufficientBalance 表示钱包余额不足。 // InsufficientBalance 表示钱包余额不足。
InsufficientBalance Code = "INSUFFICIENT_BALANCE" InsufficientBalance Code = "INSUFFICIENT_BALANCE"

View File

@ -119,6 +119,22 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(DeviceAlreadyRegistered), publicCode: string(DeviceAlreadyRegistered),
publicMessage: "device already registered", publicMessage: "device already registered",
}, },
{
name: "cp conflict identifies current user",
code: CPAlreadyExistsSelf,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(CPAlreadyExistsSelf),
publicMessage: "You already have a CP",
},
{
name: "cp conflict identifies other party",
code: CPAlreadyExistsOther,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(CPAlreadyExistsOther),
publicMessage: "The other party already has a CP",
},
{ {
name: "region mismatch keeps forbidden transport but exposes concrete app prompt", name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
code: RegionMismatch, code: RegionMismatch,

View File

@ -0,0 +1,43 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 只新增无索引的展示列,并强制 INSTANT 算法;不支持元数据即时变更时直接失败,避免意外触发全表重建。
SET @apps_logo_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'apps'
AND COLUMN_NAME = 'logo_url'
);
SET @apps_logo_ddl = IF(
@apps_logo_column_exists = 0,
'ALTER TABLE apps ADD COLUMN logo_url VARCHAR(1024) NOT NULL DEFAULT '''' COMMENT ''应用 Logo 的公网访问地址'', ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE apps_logo_stmt FROM @apps_logo_ddl;
EXECUTE apps_logo_stmt;
DEALLOCATE PREPARE apps_logo_stmt;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- app_code 有唯一索引,五个常量键只会做定点查找;不在 apps 表的 legacy App 由 admin-server 财务源配置提供 Logo。
UPDATE apps
SET logo_url = CASE app_code
WHEN 'lalu' THEN 'https://media.haiyihy.com/admin/apps/logos/lalu.png'
WHEN 'huwaa' THEN 'https://media.haiyihy.com/admin/apps/logos/huwaa.png'
WHEN 'fami' THEN 'https://media.haiyihy.com/admin/apps/logos/fami.png'
WHEN 'aslan' THEN 'https://media.haiyihy.com/admin/apps/logos/aslan.png'
WHEN 'yumi' THEN 'https://media.haiyihy.com/admin/apps/logos/yumi.png'
ELSE logo_url
END,
updated_at_ms = @now_ms
WHERE app_code IN ('lalu', 'huwaa', 'fami', 'aslan', 'yumi')
AND logo_url <> CASE app_code
WHEN 'lalu' THEN 'https://media.haiyihy.com/admin/apps/logos/lalu.png'
WHEN 'huwaa' THEN 'https://media.haiyihy.com/admin/apps/logos/huwaa.png'
WHEN 'fami' THEN 'https://media.haiyihy.com/admin/apps/logos/fami.png'
WHEN 'aslan' THEN 'https://media.haiyihy.com/admin/apps/logos/aslan.png'
WHEN 'yumi' THEN 'https://media.haiyihy.com/admin/apps/logos/yumi.png'
ELSE logo_url
END;

View File

@ -0,0 +1,555 @@
#!/usr/bin/env python3
"""用 Chrome DevTools Protocol 模拟 Flutter WebView 打开线上 ZGame。
脚本复刻 Flutter 容器最关键的三个边界先向线上网关登录并签发一次性
launch token在文档首行注入 launch payload/config再执行后端下发的远程
bridge 脚本账号密码只从环境变量读取避免进入代码shell history 或截图
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import pathlib
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
try:
import websocket
except ImportError as exc: # pragma: no cover - 只在本机缺少运行依赖时触发。
raise SystemExit(
"缺少 websocket-client请先执行: python3 -m pip install --user websocket-client"
) from exc
DEFAULT_API_BASE = "https://api.global-interaction.com/api/v1"
DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
LOCAL_HTTP_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="登录线上账号,并用 Flutter WebView 同等桥接顺序打开 ZGame。"
)
parser.add_argument("--api-base", default=DEFAULT_API_BASE)
parser.add_argument("--app-code", default="lalu")
parser.add_argument("--account", default="123456")
parser.add_argument("--game-id", default="zgame_49")
parser.add_argument("--password-env", default="HYAPP_TEST_PASSWORD")
parser.add_argument("--chrome", default=DEFAULT_CHROME)
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument(
"--screenshot",
default=".tmp/zgame-flutter-container-check.png",
help="验证截图输出位置;.tmp 已被仓库忽略。",
)
return parser.parse_args()
def http_json(
url: str,
*,
method: str = "GET",
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 20,
) -> dict[str, Any]:
encoded = None if body is None else json.dumps(body).encode("utf-8")
request_headers = {
"Accept": "application/json",
"Content-Type": "application/json",
**(headers or {}),
}
request = urllib.request.Request(
url,
data=encoded,
headers=request_headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.load(response)
except urllib.error.HTTPError as exc:
# 错误正文通常包含明确业务码;只输出响应,不输出请求体中的密码/token。
detail = exc.read().decode("utf-8", errors="replace")[:1000]
raise RuntimeError(f"HTTP {exc.code} {url}: {detail}") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"接口未返回 JSON object: {url}")
return payload
def require_envelope(payload: dict[str, Any], operation: str) -> dict[str, Any]:
code = str(payload.get("code", "")).upper()
if code not in {"OK", "0"}:
raise RuntimeError(
f"{operation}失败: code={payload.get('code')} message={payload.get('message')}"
)
data = payload.get("data")
if not isinstance(data, dict):
raise RuntimeError(f"{operation}响应缺少 data")
return data
def reserve_port() -> int:
# 先由内核选择空闲端口Chrome 随后立即占用;本机验证避免固定调试端口冲突。
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def wait_json(url: str, timeout_seconds: int) -> dict[str, Any]:
deadline = time.monotonic() + timeout_seconds
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
# 本机 CDP 不能经过系统 HTTP(S)_PROXY否则 127.0.0.1 会被代理吞掉并超时。
with LOCAL_HTTP_OPENER.open(url, timeout=1) as response:
value = json.load(response)
if isinstance(value, dict):
return value
except Exception as exc: # noqa: BLE001 - Chrome 启动期间连接失败是预期状态。
last_error = exc
time.sleep(0.1)
raise RuntimeError(f"Chrome DevTools 未就绪: {last_error}")
class CDPClient:
"""最小 CDP 客户端,只实现本验证所需的调用、事件收集和超时边界。"""
def __init__(self, websocket_url: str, timeout_seconds: int) -> None:
endpoint = urllib.parse.urlsplit(websocket_url)
# websocket-client 即使显式传空 proxy 也可能继续读取系统代理;预建 TCP
# socket 可确保 CDP 永远直连本机 Chrome不把调试流量送出机器。
raw_socket = socket.create_connection(
(endpoint.hostname or "127.0.0.1", endpoint.port or 80),
timeout=timeout_seconds,
)
self._socket = websocket.create_connection(
websocket_url,
timeout=timeout_seconds,
origin="http://127.0.0.1",
socket=raw_socket,
)
self._socket.settimeout(1)
self._next_id = 1
self.events: list[dict[str, Any]] = []
def close(self) -> None:
self._socket.close()
def call(
self,
method: str,
params: dict[str, Any] | None = None,
*,
timeout_seconds: int = 15,
) -> dict[str, Any]:
request_id = self._next_id
self._next_id += 1
self._socket.send(
json.dumps({"id": request_id, "method": method, "params": params or {}})
)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
try:
message = json.loads(self._socket.recv())
except websocket.WebSocketTimeoutException:
continue
if message.get("id") == request_id:
if "error" in message:
raise RuntimeError(f"CDP {method}失败: {message['error']}")
result = message.get("result")
return result if isinstance(result, dict) else {}
self.events.append(message)
raise RuntimeError(f"CDP {method}调用超时")
def drain(self, duration_seconds: float = 0.1) -> None:
deadline = time.monotonic() + duration_seconds
while time.monotonic() < deadline:
try:
self.events.append(json.loads(self._socket.recv()))
except websocket.WebSocketTimeoutException:
return
def fetch_text(url: str, timeout: int = 20) -> str:
request = urllib.request.Request(url, headers={"Accept": "application/javascript,*/*"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8")
def build_flutter_document_start_script(
*, game_id: str, launch_url: str, session_id: str, bridge_source: str
) -> str:
query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(launch_url).query))
# Flutter 会把 URL query、后端 launch data 和目录元数据合并ZGame 取身份字段时
# 依赖 query 中的 openId/js_codeadapterType 则决定安装 ThirdPlatformBridge。
config: dict[str, Any] = {
**query,
"adapterType": "zgame_v1",
"adapter_type": "zgame_v1",
"platformCode": "zgame",
"platform_code": "zgame",
}
payload = {
"sessionId": session_id,
"gameSessionId": session_id,
"gameId": game_id,
"provider": "zgame",
"vendorType": "zgame",
"launchUrl": launch_url,
"launchConfig": config,
"entry": {"entryUrl": launch_url},
}
# 原生通道在模拟器中只记录动作;用户身份和 login 凭证由远程 bridge 同步返回,
# 不需要伪造厂商回调,也不会把 token 发送到除即构与线上 API 之外的域名。
bootstrap = f"""
window.hyappGameLaunchPayload = {json.dumps(payload, ensure_ascii=False)};
window.hyappGameLaunchConfig = {json.dumps(config, ensure_ascii=False)};
window.hyappGameEntry = {json.dumps(payload['entry'], ensure_ascii=False)};
window.addEventListener('unhandledrejection', function(event) {{
var reason = event && event.reason;
try {{ console.error('[FlutterSimulatorUnhandledRejection]', JSON.stringify(reason)); }}
catch (_) {{ console.error('[FlutterSimulatorUnhandledRejection]', String(reason)); }}
}});
window.addEventListener('error', function(event) {{
console.error('[FlutterSimulatorError]', event && event.message || 'unknown error');
}});
window.GameBridgeChannel = {{
postMessage: function(message) {{ console.info('[FlutterBridge]', message); }}
}};
window.webkit = window.webkit || {{}};
window.webkit.messageHandlers = window.webkit.messageHandlers || {{}};
window.webkit.messageHandlers.GameBridgeChannel = window.GameBridgeChannel;
"""
return bootstrap + "\n;\n" + bridge_source
def evaluate_value(cdp: CDPClient, expression: str) -> Any:
result = cdp.call(
"Runtime.evaluate",
{"expression": expression, "returnByValue": True, "awaitPromise": True},
)
remote = result.get("result")
if not isinstance(remote, dict):
return None
return remote.get("value")
def browser_state(cdp: CDPClient) -> dict[str, Any]:
expression = r"""
(() => {
const visible = (element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return rect.width > 2 && rect.height > 2 && style.visibility !== 'hidden' && style.display !== 'none';
};
const canvases = [...document.querySelectorAll('canvas')];
return {
readyState: document.readyState,
title: document.title,
bodyText: (document.body && document.body.innerText || '').trim().slice(0, 500),
bridgeReady: !!(window.ThirdPlatformBridge && window.ThirdPlatformBridge.__hyappZGameBridge),
canvasCount: canvases.length,
visibleCanvasCount: canvases.filter(visible).length,
largestCanvasPixels: canvases.reduce((max, canvas) => Math.max(max, canvas.width * canvas.height), 0),
imageCount: document.images.length,
href: location.href
};
})()
"""
value = evaluate_value(cdp, expression)
return value if isinstance(value, dict) else {}
def relevant_browser_errors(events: list[dict[str, Any]]) -> list[str]:
errors: list[str] = []
for event in events:
method = event.get("method")
params = event.get("params") or {}
if method == "Runtime.exceptionThrown":
detail = params.get("exceptionDetails") or {}
exception = detail.get("exception") or {}
text = str(
exception.get("description")
or detail.get("text")
or exception.get("value")
or ""
)
if text:
errors.append(text[:500])
elif method == "Log.entryAdded":
entry = params.get("entry") or {}
if entry.get("level") == "error":
errors.append(str(entry.get("text") or "")[:500])
elif method == "Runtime.consoleAPICalled":
if params.get("type") not in {"error", "warning"}:
continue
values = []
for argument in params.get("args") or []:
if not isinstance(argument, dict):
continue
values.append(str(argument.get("value") or argument.get("description") or ""))
if values:
errors.append("console: " + " ".join(values)[:2000])
elif method == "Network.loadingFailed":
errors.append(
"network: "
+ str(params.get("errorText") or "loading failed")
+ " "
+ str(params.get("blockedReason") or "")
)
elif method == "Network.responseReceived":
response = params.get("response") or {}
status = int(response.get("status") or 0)
if status >= 400:
errors.append(f"http {status}: {response.get('url')}")
return list(dict.fromkeys(error for error in errors if error))
def browser_network_trace(events: list[dict[str, Any]]) -> list[str]:
"""保留末尾 XHR/fetch避免把图片/CDN 噪声混进业务登录失败诊断。"""
trace: list[str] = []
for event in events:
if event.get("method") != "Network.responseReceived":
continue
params = event.get("params") or {}
if params.get("type") not in {"XHR", "Fetch"}:
continue
response = params.get("response") or {}
trace.append(f"{int(response.get('status') or 0)} {response.get('url')}")
return trace[-20:]
def run_browser_check(
*,
chrome_path: str,
launch_url: str,
game_id: str,
session_id: str,
bridge_source: str,
screenshot_path: pathlib.Path,
timeout_seconds: int,
) -> tuple[dict[str, Any], list[str]]:
if not pathlib.Path(chrome_path).is_file():
raise RuntimeError(f"找不到 Chrome: {chrome_path}")
port = reserve_port()
profile_dir = tempfile.mkdtemp(prefix="zgame-flutter-container-")
process = subprocess.Popen(
[
chrome_path,
"--headless=new",
f"--remote-debugging-port={port}",
"--remote-allow-origins=*",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
cdp: CDPClient | None = None
try:
wait_json(f"http://127.0.0.1:{port}/json/version", 10)
with LOCAL_HTTP_OPENER.open(
f"http://127.0.0.1:{port}/json/list", timeout=2
) as response:
targets = json.load(response)
page = next((item for item in targets if item.get("type") == "page"), None)
if not isinstance(page, dict) or not page.get("webSocketDebuggerUrl"):
raise RuntimeError("Chrome 未创建可调试页面")
cdp = CDPClient(str(page["webSocketDebuggerUrl"]), timeout_seconds)
for domain in ("Page.enable", "Runtime.enable", "Log.enable", "Network.enable"):
cdp.call(domain)
cdp.call(
"Emulation.setDeviceMetricsOverride",
{
"width": 390,
"height": 844,
"deviceScaleFactor": 2,
"mobile": True,
},
)
cdp.call(
"Network.setUserAgentOverride",
{
"userAgent": (
"Mozilla/5.0 (Linux; Android 14; FlutterWebView) "
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 "
"Chrome/136.0.0.0 Mobile Safari/537.36"
)
},
)
document_start = build_flutter_document_start_script(
game_id=game_id,
launch_url=launch_url,
session_id=session_id,
bridge_source=bridge_source,
)
cdp.call("Page.addScriptToEvaluateOnNewDocument", {"source": document_start})
cdp.call("Page.navigate", {"url": launch_url})
deadline = time.monotonic() + timeout_seconds
state: dict[str, Any] = {}
while time.monotonic() < deadline:
time.sleep(1)
cdp.drain(0.05)
state = browser_state(cdp)
# 厂商加载页也有背景素材;必须同时看到桥已安装和可见 canvas才算
# 通过授权阶段进入游戏渲染,而不是把 loading 背景误报为成功。
if state.get("bridgeReady") and int(state.get("visibleCanvasCount") or 0) > 0:
break
screenshot_path.parent.mkdir(parents=True, exist_ok=True)
screenshot = cdp.call(
"Page.captureScreenshot",
{"format": "png", "captureBeyondViewport": False},
)
screenshot_path.write_bytes(base64.b64decode(str(screenshot.get("data") or "")))
cdp.drain(0.2)
errors = relevant_browser_errors(cdp.events)
network_trace = browser_network_trace(cdp.events)
if not state.get("bridgeReady"):
raise RuntimeError(
"ThirdPlatformBridge 未安装Flutter 容器模拟失败; "
+ json.dumps({"errors": errors[:10], "xhr": network_trace}, ensure_ascii=False)
)
if int(state.get("visibleCanvasCount") or 0) <= 0:
raise RuntimeError(
f"游戏未进入 canvas 渲染,最终页面状态: {json.dumps(state, ensure_ascii=False)}; "
f"诊断: {json.dumps({'errors': errors[:10], 'xhr': network_trace}, ensure_ascii=False)}"
)
return state, errors
finally:
if cdp is not None:
cdp.close()
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
shutil.rmtree(profile_dir, ignore_errors=True)
def main() -> int:
args = parse_args()
password = os.environ.get(args.password_env, "").strip()
if not password:
raise RuntimeError(f"请通过环境变量 {args.password_env} 提供线上测试密码")
api_base = args.api_base.rstrip("/")
common_headers = {"X-App-Code": args.app_code}
login = require_envelope(
http_json(
f"{api_base}/auth/account/login",
method="POST",
body={
"account": args.account,
"password": password,
"device_id": "zgame-flutter-container-check",
},
headers=common_headers,
),
"线上账号登录",
)
access_token = str(login.get("access_token") or "").strip()
if not access_token:
raise RuntimeError("线上账号登录响应缺少 access_token")
auth_headers = {**common_headers, "Authorization": f"Bearer {access_token}"}
games = require_envelope(
http_json(f"{api_base}/games?scene=voice_room", headers=auth_headers),
"读取游戏目录",
)
catalog = games.get("games")
if not isinstance(catalog, list) or not any(
isinstance(item, dict) and item.get("game_id") == args.game_id
for item in catalog
):
raise RuntimeError(f"线上 voice_room 目录找不到 {args.game_id}")
launch = require_envelope(
http_json(
f"{api_base}/games/{urllib.parse.quote(args.game_id)}/launch",
method="POST",
body={},
headers=auth_headers,
),
"签发游戏启动会话",
)
launch_url = str(launch.get("launch_url") or "").strip()
session_id = str(launch.get("session_id") or "").strip()
if not launch_url or not session_id:
raise RuntimeError("启动响应缺少 launch_url/session_id")
query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(launch_url).query))
bridge_url = str(query.get("bridge_script_url") or query.get("bridgeScriptUrl") or "")
open_id = str(query.get("open_id") or query.get("openId") or "")
js_code = str(query.get("js_code") or query.get("jscode") or "")
callback_base = str(query.get("callback_base") or query.get("callbackBase") or "").rstrip("/")
if not bridge_url or not open_id or not js_code or not callback_base:
raise RuntimeError("启动链接缺少 bridge/open_id/js_code/callback_base")
# 先直接验证服务端 login 回调,再让浏览器走一遍厂商 H5两层都通过才能区分
# “桥接显示正常”与“实际会话无法登录”。login 不消耗 token页面仍可继续使用。
callback = http_json(
f"{callback_base}/api/server/login",
method="POST",
body={"open_id": open_id, "js_code": js_code},
headers=common_headers,
)
callback_data = require_envelope(callback, "ZGame 服务端登录回调")
if not str(callback_data.get("session") or "").strip():
raise RuntimeError("ZGame 服务端登录回调未返回 session")
screenshot_path = pathlib.Path(args.screenshot).expanduser().resolve()
# 先输出本次短期链接;即使 H5 最终失败,调用方仍能复现同一厂商会话。
print(f"expires_at_ms={launch.get('expires_at_ms')}", flush=True)
print(f"launch_url={launch_url}", flush=True)
state, browser_errors = run_browser_check(
chrome_path=args.chrome,
launch_url=launch_url,
game_id=args.game_id,
session_id=session_id,
bridge_source=fetch_text(bridge_url),
screenshot_path=screenshot_path,
timeout_seconds=args.timeout,
)
print("ZGame Flutter 容器模拟验证通过")
print(f"account={args.account} game_id={args.game_id} session_id={session_id}")
print(
"browser_state="
+ json.dumps(
{
"bridge_ready": state.get("bridgeReady"),
"visible_canvas_count": state.get("visibleCanvasCount"),
"largest_canvas_pixels": state.get("largestCanvasPixels"),
"title": state.get("title"),
},
ensure_ascii=False,
)
)
print(f"browser_error_count={len(browser_errors)}")
for error in browser_errors[:5]:
print(f"browser_error={error}")
print(f"screenshot={screenshot_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # noqa: BLE001 - CLI 统一输出可执行失败原因。
print(f"验证失败: {exc}", file=sys.stderr)
raise SystemExit(1) from exc

View File

@ -18,7 +18,6 @@ import (
"hyapp-admin-server/internal/cache" "hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/activityclient"
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
"hyapp-admin-server/internal/integration/gameclient" "hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/googleorders" "hyapp-admin-server/internal/integration/googleorders"
"hyapp-admin-server/internal/integration/luckygiftadmin" "hyapp-admin-server/internal/integration/luckygiftadmin"
@ -44,13 +43,13 @@ import (
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard" dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi" databimodule "hyapp-admin-server/internal/modules/databi"
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
financeordermodule "hyapp-admin-server/internal/modules/financeorder" financeordermodule "hyapp-admin-server/internal/modules/financeorder"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal" financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward" firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice" fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement" gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond" giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond"
giftrecordmodule "hyapp-admin-server/internal/modules/giftrecord"
healthmodule "hyapp-admin-server/internal/modules/health" healthmodule "hyapp-admin-server/internal/modules/health"
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy" hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg" hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
@ -320,24 +319,6 @@ func main() {
} }
objectUploader = uploader objectUploader = uploader
} }
var financeApplicationOptions []financeapplicationmodule.Option
if cfg.FinanceNotifications.DingTalk.Enabled && strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL) != "" {
client, err := dingtalkclient.New(dingtalkclient.Config{
WebhookURL: cfg.FinanceNotifications.DingTalk.WebhookURL,
Secret: cfg.FinanceNotifications.DingTalk.Secret,
AtMobiles: cfg.FinanceNotifications.DingTalk.AtMobiles,
AtAll: cfg.FinanceNotifications.DingTalk.AtAll,
RequestTimeout: cfg.FinanceNotifications.DingTalk.RequestTimeout,
}, nil)
if err != nil {
fatalRuntime("create_finance_dingtalk_client_failed", err)
}
financeApplicationOptions = append(financeApplicationOptions, financeapplicationmodule.WithApplicationNotifier(financeapplicationmodule.NewDingTalkNotifier(client)))
}
financeApplicationOptions = append(financeApplicationOptions,
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
)
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。 // 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
dashboardService := dashboardmodule.NewService( dashboardService := dashboardmodule.NewService(
store, store,
@ -365,7 +346,7 @@ func main() {
appRegistryService, appRegistryService,
userclient.NewGRPC(userConn), userclient.NewGRPC(userConn),
databimodule.WithFinanceRechargeOverviewSource(paymentHandler), databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...), databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...), databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
) )
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler) luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler)
@ -376,7 +357,7 @@ func main() {
AgencyOpening: agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler), AgencyOpening: agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler), AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB), AppRegistry: appregistrymodule.New(userDB, store),
AppUser: appUserHandler, AppUser: appUserHandler,
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler), CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryRegionHandler, CountryRegion: countryRegionHandler,
@ -387,10 +368,14 @@ func main() {
Dashboard: dashboardmodule.NewWithService(dashboardService), Dashboard: dashboardmodule.NewWithService(dashboardService),
Databi: databimodule.New(databiService, store, auditHandler), Databi: databimodule.New(databiService, store, auditHandler),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...), FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler,
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)), financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler), financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
})),
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Game: gamemanagementmodule.New( Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn), gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn), userclient.NewGRPC(userConn),
@ -399,6 +384,7 @@ func main() {
gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)), gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)),
), ),
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler), GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
GiftRecord: giftrecordmodule.New(userDB, walletDB),
Health: healthmodule.New(store, redisClient, jobStatus), Health: healthmodule.New(store, redisClient, jobStatus),
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler), HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
@ -434,7 +420,7 @@ func main() {
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler), WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
} }
engine := router.New(cfg, auth, handlers) engine := router.New(cfg, auth, store, handlers)
server := &http.Server{ server := &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
@ -657,10 +643,17 @@ func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, config
} }
// databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录; // databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录;
// 同一 App 配多个源(如 cdc MySQL + Mongo 补充)时只取一次 // Logo 复用财务范围的 legacy App 目录,避免 dashboard、账单和 Social BI 各自维护同一份品牌元数据
func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimodule.LegacyAppDescriptor { func databiLegacyApps(configs []config.DashboardExternalSourceConfig, regionConfigs []config.MoneyRegionSourceConfig) []databimodule.LegacyAppDescriptor {
out := []databimodule.LegacyAppDescriptor{} out := []databimodule.LegacyAppDescriptor{}
seen := map[string]struct{}{} seen := map[string]struct{}{}
logoByAppCode := map[string]string{}
for _, regionConfig := range regionConfigs {
appCode := strings.ToLower(strings.TrimSpace(regionConfig.AppCode))
if appCode != "" {
logoByAppCode[appCode] = strings.TrimSpace(regionConfig.LogoURL)
}
}
for _, sourceConfig := range configs { for _, sourceConfig := range configs {
if !sourceConfig.Enabled { if !sourceConfig.Enabled {
continue continue
@ -673,7 +666,11 @@ func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimo
continue continue
} }
seen[appCode] = struct{}{} seen[appCode] = struct{}{}
out = append(out, databimodule.LegacyAppDescriptor{AppCode: appCode, AppName: strings.TrimSpace(sourceConfig.AppName)}) out = append(out, databimodule.LegacyAppDescriptor{
AppCode: appCode,
AppName: strings.TrimSpace(sourceConfig.AppName),
LogoURL: logoByAppCode[appCode],
})
} }
return out return out
} }

View File

@ -18,6 +18,7 @@ money_region_sources:
- enabled: true - enabled: true
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000" mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000"
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -26,6 +27,7 @@ money_region_sources:
- enabled: true - enabled: true
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred" mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou" mongo_database: "atyou"
@ -36,6 +38,7 @@ finance_bill_sources:
- enabled: true - enabled: true
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000" mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000"
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -47,6 +50,7 @@ finance_bill_sources:
- enabled: true - enabled: true
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred" mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou" mongo_database: "atyou"
@ -147,14 +151,6 @@ legacy_coin_seller_recharge_sources:
app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai" app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai" wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
request_timeout: "5s" request_timeout: "5s"
finance_notifications:
dingtalk:
enabled: true
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
at_mobiles: []
at_all: false
request_timeout: "3s"
tencent-cos: tencent-cos:
enabled: true enabled: true
secret-id: "REPLACE_ME" secret-id: "REPLACE_ME"

View File

@ -18,6 +18,7 @@ money_region_sources:
- enabled: false - enabled: false
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "" mongo_uri: ""
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -26,6 +27,7 @@ money_region_sources:
- enabled: false - enabled: false
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "" mongo_uri: ""
mongo_database: "atyou" mongo_database: "atyou"
@ -36,6 +38,7 @@ finance_bill_sources:
- enabled: false - enabled: false
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "" mongo_uri: ""
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -47,6 +50,7 @@ finance_bill_sources:
- enabled: false - enabled: false
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "" mongo_uri: ""
mongo_database: "atyou" mongo_database: "atyou"
@ -144,14 +148,6 @@ legacy_coin_seller_recharge_sources:
app_mysql_dsn: "" app_mysql_dsn: ""
wallet_mysql_dsn: "" wallet_mysql_dsn: ""
request_timeout: "5s" request_timeout: "5s"
finance_notifications:
dingtalk:
enabled: true
webhook_url: "https://oapi.dingtalk.com/robot/send?access_token=08ab491397880d167bb86168749eea5fff35aaf83eac9d033157ea2a7c96573c"
secret: "SEC893f814c59c7dde7916086d7b2a694ebc6796448c622c804ffa2c784c69adc03"
at_mobiles: []
at_all: false
request_timeout: "3s"
tencent-cos: tencent-cos:
enabled: true enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu" secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"

View File

@ -36,6 +36,16 @@
- `platform-admin` 当前会绑定全部权限;其他预置角色需要补默认权限绑定策略。 - `platform-admin` 当前会绑定全部权限;其他预置角色需要补默认权限绑定策略。
- `菜单权限` 页面当前只读,没有菜单、权限定义的增删改能力。 - `菜单权限` 页面当前只读,没有菜单、权限定义的增删改能力。
## 用户 App 可见范围
角色权限决定“能做什么”,用户 App 范围决定“能在哪个 App 中做”。两者必须同时满足:
- `admin_users.app_scope_mode=all`:主后台可以切换所有当前启用 App。
- `selected`:只允许切换 `admin_user_app_scopes` 中显式绑定的 App。
- `none`:主后台不发送 `X-App-Code`,只能进入通用工作台;财务工作台和运营工作台入口仍按 `finance:view``overview:view` 控制。
- 财务工作台的 App/区域数据范围继续由 `admin_user_money_scopes` 管理,不能与主后台 App 范围合并。
- App 级 API 除角色权限外还必须通过后端 App scope 中间件校验,不能只依赖前端隐藏下拉项。
## 权限类型 ## 权限类型
权限 `kind` 使用以下值: 权限 `kind` 使用以下值:
@ -328,48 +338,54 @@
## 角色预设 ## 角色预设
### 平台管理员 ### 超级管理员
`platform-admin` `platform-admin`
- 拥有全部权限。 - 拥有全部权限。
- 能维护用户、角色、权限、菜单和所有业务模块。 - 能维护用户、角色、权限、菜单和所有业务模块。
### 运维管理员 ### 运营负责人
`ops-admin` `ops-admin`
建议权限: - 负责运营、团队、房间、资源、活动和游戏的日常管理。
- 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。
- `overview:view` ### 运营专员
- `service:view`
- `service:create`
- `service:update`
- `service:operate`
- `user:view`
- `user:status`
- `log:view`
### 审计员 `operations-specialist`
`auditor` - 可执行用户、团队结构、房间、资源和常规运营动作。
- 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。
建议权限: ### 产品负责人
- `overview:view` `product-lead`
- `log:view`
- `user:view`
- `role:view`
- `permission:view`
### 只读用户 - 可管理 APP 配置、版本、房间、资源、活动和游戏。
- 团队、支付、地区和 App 用户主资料按表格保持只读或限定动作。
`readonly` ### 产品专员
建议权限: `product-specialist`
- 所有已开放模块的 `*:view` - 负责资源配置;房间仅可管理机器人配置。
- 不包含创建、更新、删除、导出、状态变更、重置密码、授权等按钮权限。 - APP 配置、活动、游戏、支付和地区模块保持只读。
### 财务负责人
`finance-lead`
- 只进入财务看板、充值对账、提现审核和房内猜拳订单。
### 财务专员
`finance-specialist`
- 当前与财务负责人使用同一权限矩阵;保留独立角色用于人员分工和后续数据范围配置。
固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor``readonly` 和用户自建角色不会被删除或重置。
## 前端落地规则 ## 前端落地规则
@ -535,15 +551,13 @@
- 创建、更新、删除、授权、导出、批量和状态类操作都有操作日志。 - 创建、更新、删除、授权、导出、批量和状态类操作都有操作日志。
- 全局搜索结果按当前用户权限过滤。 - 全局搜索结果按当前用户权限过滤。
- `platform-admin` 拥有所有权限。 - `platform-admin` 拥有所有权限。
- `readonly` 只有查看权限,不能触发任何写操作。 - 六个岗位角色的权限与 `093_role_permission_matrix.sql` 及后端默认矩阵完全一致。
- 游戏列表、自研游戏、全站机器人、房内猜拳配置和房内猜拳订单互不借用查看或更新权限。
- 支付账单导出/刷新、支付方式更新/同步、汇率编辑/同步互不借用列表查看权限。
## 实施顺序 ## 维护规则
1. 拆分并补齐权限种子:新增 `permission:*``menu:*``role:create/update/delete/permission``user:export``log:export` 1. 新增页面时先定义独立的 `*:view`,再按真实副作用拆按钮权限。
2. 后端补权限定义和菜单管理接口。 2. 不允许为了复用一个 Handler把相邻菜单重新绑定到同一个宽权限。
3. 后端把现有接口从宽权限调整到按钮级权限。 3. 固定岗位矩阵变更必须同时更新默认种子、版本化迁移和矩阵一致性测试。
4. 前端补角色授权抽屉和菜单权限页编辑能力。 4. 旧权限码只能作为自定义历史角色的兼容入口,不能重新分配给七类固定岗位。
5. 前端替换按钮权限码,导出、已读等动作不再复用查看权限。
6. 补默认角色权限绑定策略。
7. 补搜索结果权限过滤。
8. 补后端权限相关测试和前端构建验证。

View File

@ -49,7 +49,6 @@ type Config struct {
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
} }
type MigrationConfig struct { type MigrationConfig struct {
@ -127,19 +126,6 @@ type LegacyCoinSellerRechargeSourceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"` RequestTimeout time.Duration `yaml:"request_timeout"`
} }
type FinanceNotificationsConfig struct {
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
}
type DingTalkRobotConfig struct {
Enabled bool `yaml:"enabled"`
WebhookURL string `yaml:"webhook_url"`
Secret string `yaml:"secret"`
AtMobiles []string `yaml:"at_mobiles"`
AtAll bool `yaml:"at_all"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type RobotProfileSourceConfig struct { type RobotProfileSourceConfig struct {
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
RequestTimeout time.Duration `yaml:"request_timeout"` RequestTimeout time.Duration `yaml:"request_timeout"`
@ -149,6 +135,7 @@ type MoneyRegionSourceConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"` AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"` AppName string `yaml:"app_name"`
LogoURL string `yaml:"logo_url"`
SysOrigin string `yaml:"sys_origin"` SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"` MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"` MongoDatabase string `yaml:"mongo_database"`
@ -162,6 +149,7 @@ type FinanceBillSourceConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"` AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"` AppName string `yaml:"app_name"`
LogoURL string `yaml:"logo_url"`
SysOrigin string `yaml:"sys_origin"` SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"` MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"` MongoDatabase string `yaml:"mongo_database"`
@ -237,6 +225,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
MongoDatabase: "tarab_all", MongoDatabase: "tarab_all",
MongoCollection: "sys_region_config", MongoCollection: "sys_region_config",
@ -246,6 +235,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "aslan", AppCode: "aslan",
AppName: "Aslan", AppName: "Aslan",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/aslan.png",
SysOrigin: "ATYOU", SysOrigin: "ATYOU",
MongoDatabase: "tarab_all", MongoDatabase: "tarab_all",
MongoCollection: "sys_region_config", MongoCollection: "sys_region_config",
@ -257,6 +247,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
MongoDatabase: "tarab_all", MongoDatabase: "tarab_all",
MongoCollection: "in_app_purchase_details", MongoCollection: "in_app_purchase_details",
@ -266,6 +257,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "aslan", AppCode: "aslan",
AppName: "Aslan", AppName: "Aslan",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/aslan.png",
// Aslanlikei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。 // Aslanlikei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。
SysOrigin: "ATYOU", SysOrigin: "ATYOU",
MongoDatabase: "atyou", MongoDatabase: "atyou",
@ -366,12 +358,6 @@ func Default() Config {
RequestTimeout: 5 * time.Second, RequestTimeout: 5 * time.Second,
}, },
}, },
FinanceNotifications: FinanceNotificationsConfig{
DingTalk: DingTalkRobotConfig{
Enabled: true,
RequestTimeout: 3 * time.Second,
},
},
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{ FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
Enabled: true, Enabled: true,
PollInterval: 15 * time.Second, PollInterval: 15 * time.Second,
@ -569,13 +555,6 @@ func (cfg *Config) Normalize() {
} }
} }
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
cfg.applyFinanceNotificationEnvOverrides()
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
cfg.FinanceNotifications.DingTalk.AtMobiles = compactStrings(cfg.FinanceNotifications.DingTalk.AtMobiles)
if cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
cfg.FinanceNotifications.DingTalk.RequestTimeout = 3 * time.Second
}
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 { if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
@ -585,6 +564,7 @@ func (cfg *Config) Normalize() {
// legacy 区域来源只服务财务范围目录这里先规整配置handler 层只处理业务过滤,不重复处理大小写和默认集合名。 // legacy 区域来源只服务财务范围目录这里先规整配置handler 层只处理业务过滤,不重复处理大小写和默认集合名。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName) source.AppName = strings.TrimSpace(source.AppName)
source.LogoURL = strings.TrimSpace(source.LogoURL)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI) source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/") source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
@ -605,6 +585,7 @@ func (cfg *Config) Normalize() {
// legacy 账单源只服务财务充值明细配置层统一大小写和默认集合handler 只按 app_code 命中。 // legacy 账单源只服务财务充值明细配置层统一大小写和默认集合handler 只按 app_code 命中。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName) source.AppName = strings.TrimSpace(source.AppName)
source.LogoURL = strings.TrimSpace(source.LogoURL)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI) source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/") source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
@ -826,14 +807,6 @@ func (cfg Config) Validate() error {
return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index) return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index)
} }
} }
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.WebhookURL != "" {
if !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "http://") && !strings.HasPrefix(cfg.FinanceNotifications.DingTalk.WebhookURL, "https://") {
return errors.New("finance_notifications.dingtalk.webhook_url must be an absolute url")
}
}
if cfg.FinanceNotifications.DingTalk.Enabled && cfg.FinanceNotifications.DingTalk.RequestTimeout <= 0 {
return errors.New("finance_notifications.dingtalk.request_timeout must be greater than 0")
}
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
} }
@ -917,26 +890,6 @@ func (cfg Config) Validate() error {
return nil return nil
} }
func (cfg *Config) applyFinanceNotificationEnvOverrides() {
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_ENABLED")); value != "" {
cfg.FinanceNotifications.DingTalk.Enabled = parseBoolEnv(value)
}
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "FINANCE_DINGTALK_WEBHOOK_URL")); value != "" {
// 机器人 Webhook 属于密钥级配置;生产可以只在运行环境注入,避免把真实 access_token 写进仓库里的 YAML。
cfg.FinanceNotifications.DingTalk.WebhookURL = value
cfg.FinanceNotifications.DingTalk.Enabled = true
}
if value := strings.TrimSpace(firstEnv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "FINANCE_DINGTALK_SECRET")); value != "" {
cfg.FinanceNotifications.DingTalk.Secret = value
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES")); value != "" {
cfg.FinanceNotifications.DingTalk.AtMobiles = strings.Split(value, ",")
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL")); value != "" {
cfg.FinanceNotifications.DingTalk.AtAll = parseBoolEnv(value)
}
}
func (cfg *Config) applyDashboardExternalSourceEnvOverrides() { func (cfg *Config) applyDashboardExternalSourceEnvOverrides() {
for index := range cfg.DashboardExternalSources { for index := range cfg.DashboardExternalSources {
source := &cfg.DashboardExternalSources[index] source := &cfg.DashboardExternalSources[index]

View File

@ -34,12 +34,6 @@ func TestLoadConfigYAML(t *testing.T) {
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" { if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
t.Fatalf("TencentCOS = %#v", cfg.TencentCOS) t.Fatalf("TencentCOS = %#v", cfg.TencentCOS)
} }
if !cfg.FinanceNotifications.DingTalk.Enabled {
t.Fatalf("finance dingtalk notification should be enabled by default")
}
if cfg.FinanceNotifications.DingTalk.WebhookURL == "" || cfg.FinanceNotifications.DingTalk.Secret == "" {
t.Fatalf("finance dingtalk notification config should be populated: %#v", cfg.FinanceNotifications.DingTalk)
}
} }
func TestLoadEmptyPathUsesDefault(t *testing.T) { func TestLoadEmptyPathUsesDefault(t *testing.T) {
@ -53,26 +47,6 @@ func TestLoadEmptyPathUsesDefault(t *testing.T) {
} }
} }
func TestFinanceDingTalkEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_WEBHOOK_URL", "https://oapi.dingtalk.com/robot/send?access_token=test")
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_SECRET", "SEC_TEST")
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_MOBILES", "13800138000, 13900139000")
t.Setenv("HYAPP_ADMIN_FINANCE_DINGTALK_AT_ALL", "false")
cfg := Default()
cfg.Normalize()
if !cfg.FinanceNotifications.DingTalk.Enabled {
t.Fatal("DingTalk should be enabled when webhook env is provided")
}
if cfg.FinanceNotifications.DingTalk.Secret != "SEC_TEST" {
t.Fatalf("secret mismatch: %q", cfg.FinanceNotifications.DingTalk.Secret)
}
if len(cfg.FinanceNotifications.DingTalk.AtMobiles) != 2 {
t.Fatalf("at mobiles mismatch: %#v", cfg.FinanceNotifications.DingTalk.AtMobiles)
}
}
func TestAslanMongoEnvOverride(t *testing.T) { func TestAslanMongoEnvOverride(t *testing.T) {
t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin") t.Setenv("HYAPP_ADMIN_ASLAN_MONGO_URI", "mongodb://mongouser:secret@example:27017/test?authSource=admin")
t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard") t.Setenv("HYAPP_ADMIN_ASLAN_DASHBOARD_MONGO_DATABASE", "test_dashboard")

View File

@ -63,6 +63,7 @@ type Client interface {
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error) UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, req *activityv1.UpsertLevelRuleRequest) (*activityv1.UpsertLevelRuleResponse, error) UpsertLevelRule(ctx context.Context, req *activityv1.UpsertLevelRuleRequest) (*activityv1.UpsertLevelRuleResponse, error)
UpsertLevelTier(ctx context.Context, req *activityv1.UpsertLevelTierRequest) (*activityv1.UpsertLevelTierResponse, error) UpsertLevelTier(ctx context.Context, req *activityv1.UpsertLevelTierRequest) (*activityv1.UpsertLevelTierResponse, error)
BatchUpsertLevelConfig(ctx context.Context, req *activityv1.BatchUpsertLevelConfigRequest) (*activityv1.BatchUpsertLevelConfigResponse, error)
SetUserLevel(ctx context.Context, req *activityv1.SetUserLevelRequest) (*activityv1.SetUserLevelResponse, error) SetUserLevel(ctx context.Context, req *activityv1.SetUserLevelRequest) (*activityv1.SetUserLevelResponse, error)
CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error)
CreateFanoutJob(ctx context.Context, req *activityv1.CreateFanoutJobRequest) (*activityv1.CreateFanoutJobResponse, error) CreateFanoutJob(ctx context.Context, req *activityv1.CreateFanoutJobRequest) (*activityv1.CreateFanoutJobResponse, error)
@ -320,6 +321,10 @@ func (c *GRPCClient) UpsertLevelTier(ctx context.Context, req *activityv1.Upsert
return c.growthClient.UpsertLevelTier(ctx, req) return c.growthClient.UpsertLevelTier(ctx, req)
} }
func (c *GRPCClient) BatchUpsertLevelConfig(ctx context.Context, req *activityv1.BatchUpsertLevelConfigRequest) (*activityv1.BatchUpsertLevelConfigResponse, error) {
return c.growthClient.BatchUpsertLevelConfig(ctx, req)
}
func (c *GRPCClient) SetUserLevel(ctx context.Context, req *activityv1.SetUserLevelRequest) (*activityv1.SetUserLevelResponse, error) { func (c *GRPCClient) SetUserLevel(ctx context.Context, req *activityv1.SetUserLevelRequest) (*activityv1.SetUserLevelResponse, error) {
return c.levelClient.SetUserLevel(ctx, req) return c.levelClient.SetUserLevel(ctx, req)
} }

View File

@ -1,169 +0,0 @@
package dingtalk
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Config struct {
WebhookURL string
Secret string
AtMobiles []string
AtAll bool
RequestTimeout time.Duration
}
type MarkdownMessage struct {
Title string
Text string
}
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
now func() time.Time
}
func New(config Config, httpClient *http.Client) (*Client, error) {
webhookURL := strings.TrimSpace(config.WebhookURL)
if webhookURL == "" {
return nil, errors.New("dingtalk webhook url is required")
}
if _, err := url.ParseRequestURI(webhookURL); err != nil {
return nil, fmt.Errorf("dingtalk webhook url is invalid: %w", err)
}
timeout := config.RequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
if httpClient == nil {
httpClient = &http.Client{Timeout: timeout}
}
return &Client{
webhookURL: webhookURL,
secret: strings.TrimSpace(config.Secret),
atMobiles: compactStrings(config.AtMobiles),
atAll: config.AtAll,
httpClient: httpClient,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
func (c *Client) SendMarkdown(ctx context.Context, message MarkdownMessage) error {
if c == nil || c.httpClient == nil {
return errors.New("dingtalk client is not configured")
}
title := strings.TrimSpace(message.Title)
text := strings.TrimSpace(message.Text)
if title == "" || text == "" {
return errors.New("dingtalk markdown title and text are required")
}
body, err := json.Marshal(markdownPayload{
MsgType: "markdown",
Markdown: markdownBody{
Title: title,
Text: text,
},
At: atBody{
AtMobiles: c.atMobiles,
IsAtAll: c.atAll,
},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("dingtalk returned http %d: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
var result sendResponse
if err := json.Unmarshal(payload, &result); err != nil {
return fmt.Errorf("decode dingtalk response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
func (c *Client) signedWebhookURL() string {
if c.secret == "" {
return c.webhookURL
}
parsed, err := url.Parse(c.webhookURL)
if err != nil {
return c.webhookURL
}
timestamp := c.now().UnixMilli()
query := parsed.Query()
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
query.Set("sign", sign(timestamp, c.secret))
parsed.RawQuery = query.Encode()
return parsed.String()
}
func sign(timestamp int64, secret string) string {
// 钉钉加签要求 timestamp + "\n" + secret 作为明文secret 同时作为 HMAC key签名再 Base64 并作为 URL query 参数传递。
payload := fmt.Sprintf("%d\n%s", timestamp, secret)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
type markdownPayload struct {
MsgType string `json:"msgtype"`
Markdown markdownBody `json:"markdown"`
At atBody `json:"at"`
}
type markdownBody struct {
Title string `json:"title"`
Text string `json:"text"`
}
type atBody struct {
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
}
type sendResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}

View File

@ -1,75 +0,0 @@
package dingtalk
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSendMarkdownSignsRequestAndSendsPayload(t *testing.T) {
var captured struct {
Query map[string]string
Body markdownPayload
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.Query = map[string]string{
"access_token": r.URL.Query().Get("access_token"),
"timestamp": r.URL.Query().Get("timestamp"),
"sign": r.URL.Query().Get("sign"),
}
if err := json.NewDecoder(r.Body).Decode(&captured.Body); err != nil {
t.Fatalf("decode request body: %v", err)
}
_, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok"}`))
}))
defer server.Close()
client, err := New(Config{
WebhookURL: server.URL + "?access_token=token",
Secret: "SEC_TEST",
AtMobiles: []string{" 13800138000 ", ""},
AtAll: false,
}, server.Client())
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.now = func() time.Time { return time.UnixMilli(1710000000123).UTC() }
if err := client.SendMarkdown(context.Background(), MarkdownMessage{Title: "财务申请", Text: "### 财务申请待审核"}); err != nil {
t.Fatalf("SendMarkdown() error = %v", err)
}
if captured.Query["access_token"] != "token" {
t.Fatalf("access token mismatch: %q", captured.Query["access_token"])
}
if captured.Query["timestamp"] != "1710000000123" {
t.Fatalf("timestamp mismatch: %q", captured.Query["timestamp"])
}
if captured.Query["sign"] != sign(1710000000123, "SEC_TEST") {
t.Fatalf("sign mismatch: %q", captured.Query["sign"])
}
if captured.Body.MsgType != "markdown" || captured.Body.Markdown.Title != "财务申请" || captured.Body.Markdown.Text == "" {
t.Fatalf("markdown payload mismatch: %+v", captured.Body)
}
if len(captured.Body.At.AtMobiles) != 1 || captured.Body.At.AtMobiles[0] != "13800138000" {
t.Fatalf("at mobiles mismatch: %+v", captured.Body.At.AtMobiles)
}
}
func TestSendMarkdownReturnsDingTalkError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errcode":310000,"errmsg":"keywords not in content"}`))
}))
defer server.Close()
client, err := New(Config{WebhookURL: server.URL}, server.Client())
if err != nil {
t.Fatalf("New() error = %v", err)
}
if err := client.SendMarkdown(context.Background(), MarkdownMessage{Title: "财务申请", Text: "text"}); err == nil {
t.Fatal("SendMarkdown() error = nil, want dingtalk errcode")
}
}

View File

@ -16,6 +16,10 @@ type Client interface {
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
SetGameWhitelistEnabled(ctx context.Context, req *gamev1.SetGameWhitelistEnabledRequest) (*gamev1.CatalogResponse, error)
ListGameWhitelistUsers(ctx context.Context, req *gamev1.ListGameWhitelistUsersRequest) (*gamev1.ListGameWhitelistUsersResponse, error)
AddGameWhitelistUser(ctx context.Context, req *gamev1.AddGameWhitelistUserRequest) (*gamev1.GameWhitelistUserResponse, error)
DeleteGameWhitelistUser(ctx context.Context, req *gamev1.DeleteGameWhitelistUserRequest) (*gamev1.DeleteGameWhitelistUserResponse, error)
ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error)
GetSelfGameNewUserPolicy(ctx context.Context, req *gamev1.GetSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error) GetSelfGameNewUserPolicy(ctx context.Context, req *gamev1.GetSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error)
@ -67,6 +71,22 @@ func (c *GRPCClient) DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalo
return c.client.DeleteCatalog(ctx, req) return c.client.DeleteCatalog(ctx, req)
} }
func (c *GRPCClient) SetGameWhitelistEnabled(ctx context.Context, req *gamev1.SetGameWhitelistEnabledRequest) (*gamev1.CatalogResponse, error) {
return c.client.SetGameWhitelistEnabled(ctx, req)
}
func (c *GRPCClient) ListGameWhitelistUsers(ctx context.Context, req *gamev1.ListGameWhitelistUsersRequest) (*gamev1.ListGameWhitelistUsersResponse, error) {
return c.client.ListGameWhitelistUsers(ctx, req)
}
func (c *GRPCClient) AddGameWhitelistUser(ctx context.Context, req *gamev1.AddGameWhitelistUserRequest) (*gamev1.GameWhitelistUserResponse, error) {
return c.client.AddGameWhitelistUser(ctx, req)
}
func (c *GRPCClient) DeleteGameWhitelistUser(ctx context.Context, req *gamev1.DeleteGameWhitelistUserRequest) (*gamev1.DeleteGameWhitelistUserResponse, error) {
return c.client.DeleteGameWhitelistUser(ctx, req)
}
func (c *GRPCClient) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) { func (c *GRPCClient) ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error) {
return c.client.ListSelfGames(ctx, req) return c.client.ListSelfGames(ctx, req)
} }

View File

@ -0,0 +1,52 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"github.com/gin-gonic/gin"
)
type fakeAppAccessStore struct {
access repository.AppAccess
}
func (store fakeAppAccessStore) AppAccessForUser(uint) (repository.AppAccess, error) {
return store.access, nil
}
func TestRequireAppScopeAllowsOnlySelectedApp(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextUserID, uint(7))
})
router.GET("/app", RequireAppScope(fakeAppAccessStore{access: repository.AppAccess{
Mode: model.UserAppScopeModeSelected,
AppCodes: []string{"lalu"},
}}), func(c *gin.Context) { c.Status(http.StatusNoContent) })
allowed := httptest.NewRequest(http.MethodGet, "/app", nil)
allowed.Header.Set("X-App-Code", "LALU")
allowedResponse := httptest.NewRecorder()
router.ServeHTTP(allowedResponse, allowed)
if allowedResponse.Code != http.StatusNoContent {
t.Fatalf("allowed status = %d body=%s", allowedResponse.Code, allowedResponse.Body.String())
}
for _, header := range []string{"", "huwaa"} {
request := httptest.NewRequest(http.MethodGet, "/app", nil)
if header != "" {
request.Header.Set("X-App-Code", header)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("header %q status = %d body=%s", header, response.Code, response.Body.String())
}
}
}

View File

@ -1,18 +1,25 @@
package middleware package middleware
import ( import (
"errors"
"net/http" "net/http"
"slices" "slices"
"strings" "strings"
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config" "hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
"hyapp-admin-server/internal/service" "hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type AppAccessStore interface {
AppAccessForUser(userID uint) (repository.AppAccess, error)
}
const ( const (
ContextUserID = "userID" ContextUserID = "userID"
ContextUsername = "username" ContextUsername = "username"
@ -77,6 +84,39 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
} }
} }
func RequireAppScope(store AppAccessStore) gin.HandlerFunc {
return func(c *gin.Context) {
// App 级路由必须显式携带 header不能复用 appctx 的 lalu 默认值;否则无 App 用户省略 header 仍会落入 Lalu 数据域。
appCode := strings.ToLower(strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode)))
if appCode == "" {
response.Forbidden(c, "当前用户没有可访问的 App")
c.Abort()
return
}
if store == nil {
response.ServerError(c, "App 权限服务不可用")
c.Abort()
return
}
access, err := store.AppAccessForUser(CurrentUserID(c))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Unauthorized(c, "用户不存在")
} else {
response.ServerError(c, "校验 App 权限失败")
}
c.Abort()
return
}
if !access.Allows(appCode) {
response.Forbidden(c, "当前用户无权访问该 App")
c.Abort()
return
}
c.Next()
}
}
func RequirePermission(code string) gin.HandlerFunc { func RequirePermission(code string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if HasPermission(c, code) { if HasPermission(c, code) {

View File

@ -5,6 +5,10 @@ const (
UserStatusLocked = "locked" UserStatusLocked = "locked"
UserStatusDisabled = "disabled" UserStatusDisabled = "disabled"
UserAppScopeModeAll = "all"
UserAppScopeModeSelected = "selected"
UserAppScopeModeNone = "none"
TeamProductResearchID uint = 1 TeamProductResearchID uint = 1
TeamOperationsID uint = 2 TeamOperationsID uint = 2
@ -14,10 +18,6 @@ const (
JobStatusFailed = "failed" JobStatusFailed = "failed"
JobStatusCanceled = "canceled" JobStatusCanceled = "canceled"
FinanceApplicationStatusPending = "pending"
FinanceApplicationStatusApproved = "approved"
FinanceApplicationStatusRejected = "rejected"
CoinSellerRechargeOrderStatusCreated = "created" CoinSellerRechargeOrderStatusCreated = "created"
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed" CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
CoinSellerRechargeOrderStatusVerified = "verified" CoinSellerRechargeOrderStatusVerified = "verified"
@ -48,6 +48,7 @@ type User struct {
TeamID *uint `gorm:"column:team_id;index" json:"teamId"` TeamID *uint `gorm:"column:team_id;index" json:"teamId"`
TeamRecord Team `gorm:"foreignKey:TeamID;references:ID" json:"-"` TeamRecord Team `gorm:"foreignKey:TeamID;references:ID" json:"-"`
Status string `gorm:"size:24;index;not null;default:active" json:"status"` Status string `gorm:"size:24;index;not null;default:active" json:"status"`
AppScopeMode string `gorm:"size:16;not null;default:none" json:"appScopeMode"`
MFAEnabled bool `gorm:"not null;default:false" json:"mfaEnabled"` MFAEnabled bool `gorm:"not null;default:false" json:"mfaEnabled"`
LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"` LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"`
Roles []Role `gorm:"many2many:admin_user_roles;" json:"roles"` Roles []Role `gorm:"many2many:admin_user_roles;" json:"roles"`
@ -516,6 +517,20 @@ func (UserMoneyScope) TableName() string {
return "admin_user_money_scopes" return "admin_user_money_scopes"
} }
// UserAppScope 是后台主站的 App 可见范围。它与财务区域范围彼此独立:前者决定主站可切换的 App
// 后者继续决定财务工作台可查看的 App/区域,不能复用同一张表,否则无 App 用户将无法进入通用财务工作台。
type UserAppScope struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_admin_user_app_scopes_user;index:uk_admin_user_app_scope,unique;not null" json:"userId"`
AppCode string `gorm:"size:32;index:uk_admin_user_app_scope,unique;index:idx_admin_user_app_scopes_app;not null" json:"appCode"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (UserAppScope) TableName() string {
return "admin_user_app_scopes"
}
type TemporaryPaymentLinkOwner struct { type TemporaryPaymentLinkOwner struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"` AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
OrderID string `gorm:"size:96;primaryKey" json:"orderId"` OrderID string `gorm:"size:96;primaryKey" json:"orderId"`
@ -567,36 +582,6 @@ func (LegacyGooglePaidSyncAttempt) TableName() string {
return "admin_legacy_google_paid_sync_attempts" return "admin_legacy_google_paid_sync_attempts"
} }
type FinanceApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`
Operation string `gorm:"size:64;index:idx_admin_finance_app_operation;not null" json:"operation"`
WalletIdentity string `gorm:"size:32;not null;default:''" json:"walletIdentity"`
TargetUserID string `gorm:"size:64;index:idx_admin_finance_app_target;not null" json:"targetUserId"`
CoinAmount int64 `gorm:"not null;default:0" json:"coinAmount"`
RechargeAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"rechargeAmount"`
CredentialImageURL string `gorm:"size:1024;not null;default:''" json:"credentialImageUrl"`
CredentialText string `gorm:"type:text" json:"credentialText"`
ApplicantUserID uint `gorm:"index:idx_admin_finance_app_applicant;not null" json:"applicantUserId"`
ApplicantName string `gorm:"size:64;not null;default:''" json:"applicantName"`
AuditorUserID *uint `gorm:"index:idx_admin_finance_app_auditor" json:"auditorUserId"`
AuditorName string `gorm:"size:64;not null;default:''" json:"auditorName"`
Status string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null;default:pending" json:"status"`
AuditRemark string `gorm:"type:text" json:"auditRemark"`
AuditedAtMS *int64 `gorm:"column:audited_at_ms" json:"auditedAtMs"`
WalletCommandID string `gorm:"size:128;not null;default:''" json:"walletCommandId"`
WalletTransactionID string `gorm:"size:128;not null;default:''" json:"walletTransactionId"`
WalletAssetType string `gorm:"size:64;not null;default:''" json:"walletAssetType"`
WalletAmountDelta int64 `gorm:"not null;default:0" json:"walletAmountDelta"`
WalletBalanceAfter int64 `gorm:"not null;default:0" json:"walletBalanceAfter"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (FinanceApplication) TableName() string {
return "admin_finance_applications"
}
// CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。 // CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。
type CoinSellerRechargeOrder struct { type CoinSellerRechargeOrder struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`

View File

@ -86,6 +86,26 @@ func (h *Handler) ListUserMoneyScopes(c *gin.Context) {
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)}) response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
} }
func (h *Handler) ListMoneyScopeAssignments(c *gin.Context) {
assignments, err := h.service.ListMoneyScopeAssignments()
if err != nil {
response.ServerError(c, "获取财务范围分配失败")
return
}
items := make([]gin.H, 0, len(assignments))
for _, item := range assignments {
items = append(items, gin.H{
"appCode": item.AppCode,
"regionId": item.RegionID,
"userId": item.UserID,
"userName": item.UserName,
"userAccount": item.UserAccount,
"userStatus": item.UserStatus,
})
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) { func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id") id, ok := shared.ParseID(c, "id")
if !ok { if !ok {
@ -109,6 +129,42 @@ func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)}) response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
} }
func (h *Handler) GetUserAppScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
access, err := h.service.GetUserAppScopes(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "用户不存在")
return
}
if err != nil {
response.ServerError(c, "获取 App 范围失败")
return
}
response.OK(c, appScopeDTO(access))
}
func (h *Handler) ReplaceUserAppScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
var req appScopeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "App 范围参数不正确")
return
}
access, err := h.service.ReplaceUserAppScopes(id, AppScopeInput{Mode: req.Mode, AppCodes: req.AppCodes})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "replace-user-app-scopes", "admin_user_app_scopes", "success", fmt.Sprintf("user_id=%d mode=%s apps=%d", id, access.Mode, len(access.AppCodes)))
response.OK(c, appScopeDTO(access))
}
func (h *Handler) UpdateUser(c *gin.Context) { func (h *Handler) UpdateUser(c *gin.Context) {
id, ok := shared.ParseID(c, "id") id, ok := shared.ParseID(c, "id")
if !ok { if !ok {
@ -210,3 +266,11 @@ func moneyScopeDTOs(scopes []model.UserMoneyScope) []gin.H {
} }
return out return out
} }
func appScopeDTO(access repository.AppAccess) gin.H {
appCodes := access.AppCodes
if appCodes == nil {
appCodes = []string{}
}
return gin.H{"mode": access.Mode, "appCodes": appCodes}
}

View File

@ -41,3 +41,8 @@ type moneyScopeItemRequest struct {
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"` RegionID int64 `json:"regionId"`
} }
type appScopeRequest struct {
Mode string `json:"mode" binding:"required"`
AppCodes []string `json:"appCodes"`
}

View File

@ -11,10 +11,13 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/users", middleware.RequirePermission("user:create"), h.CreateUser) protected.POST("/users", middleware.RequirePermission("user:create"), h.CreateUser)
protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers) protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers)
protected.POST("/users/batch/status", middleware.RequirePermission("user:status"), h.BatchUpdateUserStatus) protected.POST("/users/batch/status", middleware.RequirePermission("user:status"), h.BatchUpdateUserStatus)
protected.GET("/users/finance-scope-assignments", middleware.RequirePermission("user:view"), h.ListMoneyScopeAssignments)
protected.GET("/users/:id", middleware.RequirePermission("user:view"), h.GetUser) protected.GET("/users/:id", middleware.RequirePermission("user:view"), h.GetUser)
protected.GET("/users/:id/app-scopes", middleware.RequirePermission("user:view"), h.GetUserAppScopes)
protected.GET("/users/:id/finance-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes) protected.GET("/users/:id/finance-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.GET("/users/:id/money-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes) protected.GET("/users/:id/money-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.PATCH("/users/:id", middleware.RequirePermission("user:update"), h.UpdateUser) protected.PATCH("/users/:id", middleware.RequirePermission("user:update"), h.UpdateUser)
protected.PUT("/users/:id/app-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserAppScopes)
protected.PUT("/users/:id/finance-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes) protected.PUT("/users/:id/finance-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PUT("/users/:id/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes) protected.PUT("/users/:id/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus) protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus)

View File

@ -65,6 +65,11 @@ type MoneyScopeInput struct {
RegionID int64 RegionID int64
} }
type AppScopeInput struct {
Mode string
AppCodes []string
}
func NewService(store *repository.Store, cfg config.Config) *AdminUserService { func NewService(store *repository.Store, cfg config.Config) *AdminUserService {
return &AdminUserService{store: store, cfg: cfg} return &AdminUserService{store: store, cfg: cfg}
} }
@ -98,6 +103,8 @@ func (s *AdminUserService) CreateUser(input CreateUserInput) (*model.User, Passw
Team: teamName, Team: teamName,
TeamID: teamID, TeamID: teamID,
Status: shared.DefaultString(input.Status, model.UserStatusActive), Status: shared.DefaultString(input.Status, model.UserStatusActive),
// 新用户先以零 App 权限落库,后续独立 scope 请求即使失败也不会产生短暂的全 App 越权窗口。
AppScopeMode: model.UserAppScopeModeNone,
MFAEnabled: input.MFAEnabled, MFAEnabled: input.MFAEnabled,
} }
if err := s.store.CreateUser(&user, input.RoleIDs); err != nil { if err := s.store.CreateUser(&user, input.RoleIDs); err != nil {
@ -118,6 +125,42 @@ func (s *AdminUserService) ListUserMoneyScopes(id uint) ([]model.UserMoneyScope,
return s.store.ListUserMoneyScopes(id) return s.store.ListUserMoneyScopes(id)
} }
// MoneyScopeAssignment 描述某个 (App, 区域) 当前被哪位后台用户持有,用于分配弹窗把他人已占的区域置灰。
type MoneyScopeAssignment struct {
UserID uint
UserName string
UserAccount string
UserStatus string
AppCode string
RegionID int64
}
func (s *AdminUserService) ListMoneyScopeAssignments() ([]MoneyScopeAssignment, error) {
scopes, err := s.store.ListAllUserMoneyScopes()
if err != nil {
return nil, err
}
userIDs := make([]uint, 0, len(scopes))
for _, scope := range scopes {
userIDs = append(userIDs, scope.UserID)
}
users, err := s.store.FindUsersByIDs(userIDs)
if err != nil {
return nil, err
}
out := make([]MoneyScopeAssignment, 0, len(scopes))
for _, scope := range scopes {
assignment := MoneyScopeAssignment{UserID: scope.UserID, AppCode: scope.AppCode, RegionID: scope.RegionID}
if user, ok := users[scope.UserID]; ok {
assignment.UserName = user.Name
assignment.UserAccount = user.Username
assignment.UserStatus = user.Status
}
out = append(out, assignment)
}
return out, nil
}
func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInput) ([]model.UserMoneyScope, error) { func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInput) ([]model.UserMoneyScope, error) {
scopes := make([]model.UserMoneyScope, 0, len(input)) scopes := make([]model.UserMoneyScope, 0, len(input))
for _, item := range input { for _, item := range input {
@ -132,6 +175,14 @@ func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInp
return s.store.ListUserMoneyScopes(id) return s.store.ListUserMoneyScopes(id)
} }
func (s *AdminUserService) GetUserAppScopes(id uint) (repository.AppAccess, error) {
return s.store.AppAccessForUser(id)
}
func (s *AdminUserService) ReplaceUserAppScopes(id uint, input AppScopeInput) (repository.AppAccess, error) {
return s.store.ReplaceUserAppScopes(id, input.Mode, input.AppCodes)
}
func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.User, error) { func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.User, error) {
updates := map[string]any{} updates := map[string]any{}
if input.Name != nil { if input.Name != nil {

View File

@ -3,6 +3,8 @@ package appregistry
import ( import (
"database/sql" "database/sql"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -10,10 +12,11 @@ import (
type Handler struct { type Handler struct {
service *Service service *Service
store *repository.Store
} }
func New(userDB *sql.DB) *Handler { func New(userDB *sql.DB, store *repository.Store) *Handler {
return &Handler{service: NewService(userDB)} return &Handler{service: NewService(userDB), store: store}
} }
func (h *Handler) ListApps(c *gin.Context) { func (h *Handler) ListApps(c *gin.Context) {
@ -24,3 +27,23 @@ func (h *Handler) ListApps(c *gin.Context) {
} }
response.OK(c, gin.H{"items": items, "total": len(items)}) response.OK(c, gin.H{"items": items, "total": len(items)})
} }
func (h *Handler) ListVisibleApps(c *gin.Context) {
access, err := h.store.AppAccessForUser(middleware.CurrentUserID(c))
if err != nil {
response.ServerError(c, "获取 App 权限失败")
return
}
items, err := h.service.ListApps(c.Request.Context())
if err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
visible := make([]App, 0, len(items))
for _, item := range items {
if access.Allows(item.AppCode) {
visible = append(visible, item)
}
}
response.OK(c, gin.H{"items": visible, "total": len(visible), "mode": access.Mode})
}

View File

@ -8,4 +8,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
} }
protected.GET("/admin/apps", h.ListApps) protected.GET("/admin/apps", h.ListApps)
protected.GET("/admin/apps/visible", h.ListVisibleApps)
} }

View File

@ -14,6 +14,7 @@ type App struct {
AppID int64 `json:"appId"` AppID int64 `json:"appId"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
PackageName string `json:"packageName"` PackageName string `json:"packageName"`
Platform string `json:"platform"` Platform string `json:"platform"`
Status string `json:"status"` Status string `json:"status"`
@ -30,7 +31,7 @@ func (s *Service) ListApps(ctx context.Context) ([]App, error) {
return nil, fmt.Errorf("user mysql is not configured") return nil, fmt.Errorf("user mysql is not configured")
} }
rows, err := s.userDB.QueryContext(ctx, ` rows, err := s.userDB.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms SELECT app_id, app_code, app_name, logo_url, package_name, platform, status, created_at_ms, updated_at_ms
FROM apps FROM apps
WHERE status = 'active' WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC ORDER BY app_name ASC, app_code ASC
@ -43,7 +44,7 @@ func (s *Service) ListApps(ctx context.Context) ([]App, error) {
items := []App{} items := []App{}
for rows.Next() { for rows.Next() {
var item App var item App
if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.PackageName, &item.Platform, &item.Status, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil { if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.LogoURL, &item.PackageName, &item.Platform, &item.Status, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
return nil, err return nil, err
} }
items = append(items, item) items = append(items, item)

View File

@ -0,0 +1,31 @@
package appregistry
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestListAppsReturnsLogoURL(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectQuery(`SELECT app_id, app_code, app_name, logo_url, package_name, platform, status, created_at_ms, updated_at_ms`).
WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "logo_url", "package_name", "platform", "status", "created_at_ms", "updated_at_ms"}).
AddRow(1, "lalu", "Lalu", "https://media.haiyihy.com/admin/apps/logos/lalu.png", "com.org.laluparty", "", "active", 10, 20))
items, err := NewService(db).ListApps(context.Background())
if err != nil {
t.Fatalf("list apps: %v", err)
}
if len(items) != 1 || items[0].LogoURL != "https://media.haiyihy.com/admin/apps/logos/lalu.png" {
t.Fatalf("unexpected app items: %+v", items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -54,6 +54,25 @@ type AslanMongoDashboardSource struct {
goldWaterMu sync.Mutex goldWaterMu sync.Mutex
goldWaterChecked bool goldWaterChecked bool
goldWaterAvailable bool goldWaterAvailable bool
// 留存链路的两个 Mongo 触点可注入,供单元测试用假数据驱动 applyRetention 的归属逻辑;
// 为 nil 时走真实实现。
retentionCohortsLoader func(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error)
activeUserSetLoader func(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error)
}
func (s *AslanMongoDashboardSource) retentionCohorts(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
if s.retentionCohortsLoader != nil {
return s.retentionCohortsLoader(ctx, startDate, endDate, countryFilter)
}
return s.loadRegistrationCohorts(ctx, startDate, endDate, countryFilter)
}
func (s *AslanMongoDashboardSource) activeUserSet(ctx context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
if s.activeUserSetLoader != nil {
return s.activeUserSetLoader(ctx, activeDate, userIDs)
}
return s.loadActiveUserSet(ctx, activeDate, userIDs)
} }
func (s *AslanMongoDashboardSource) hasGoldWater(ctx context.Context) bool { func (s *AslanMongoDashboardSource) hasGoldWater(ctx context.Context) bool {
@ -414,7 +433,7 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim
) )
} }
if includeRetention { if includeRetention {
tasks = append(tasks, func() error { return s.applyRetention(ctx, cohorts, grid) }) tasks = append(tasks, func() error { return s.applyRetention(ctx, startDate, countryFilter, cohorts, grid) })
} }
if err := runConcurrently(tasks); err != nil { if err := runConcurrently(tasks); err != nil {
return aslanRangeOverview{}, err return aslanRangeOverview{}, err
@ -1285,13 +1304,41 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate
return nil return nil
} }
// applyRetention 用注册 cohort × user_daily_active_log 推 D1/D7/D30目标观察日未到时基数保持 0 // applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30
// 上层会把 0 基数转成 nil避免把“还不能观察”展示成真实 0% 留存。 // 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error { //(与 Yumi 的 CDC 预聚合、statistics-service 口径一致。cohort 日无注册时基数保持 0
// 上层会把 0 基数转成 nil避免把“无 cohort”展示成真实 0% 留存。
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, startDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
location, err := time.LoadLocation(s.statTimezone) location, err := time.LoadLocation(s.statTimezone)
if err != nil { if err != nil {
return err return err
} }
// 观察日 D 的 cohort 注册日最早是 D-30区间起点之前的注册日不在主 cohorts 里,单独补一段。
// 主 cohorts 覆盖 [start, end),这里只补 [start-30, start),两边合起来覆盖全部回看窗口。
earlier, err := s.retentionCohorts(ctx, startDate.AddDate(0, 0, -30), startDate, countryFilter)
if err != nil {
return fmt.Errorf("load retention cohorts: %w", err)
}
cohortFor := func(day string) map[string][]int64 {
main, hasMain := cohorts[day]
extra, hasExtra := earlier[day]
if !hasExtra {
return main
}
if !hasMain {
return extra
}
// 请求时区与源统计时区不一致时,同一 cohort 日可能被 createTime 边界切进两个 map
// 两边用户集互斥(加载区间不相交),合并后才是完整 cohort。
merged := make(map[string][]int64, len(main)+len(extra))
for countryCode, userIDs := range main {
merged[countryCode] = userIDs
}
for countryCode, userIDs := range extra {
merged[countryCode] = append(append([]int64{}, merged[countryCode]...), userIDs...)
}
return merged
}
today := dashboardSQLDate(time.Now().In(location)) today := dashboardSQLDate(time.Now().In(location))
offsets := []struct { offsets := []struct {
days int days int
@ -1301,31 +1348,42 @@ func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts
{7, func(metric *legacyMongoMetric, users, base int64) { metric.D7Users, metric.D7Base = users, base }}, {7, func(metric *legacyMongoMetric, users, base int64) { metric.D7Users, metric.D7Base = users, base }},
{30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }}, {30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }},
} }
for day, byCountry := range cohorts { for _, day := range grid.days {
if !grid.hasDay(day) { if day > today {
continue continue
} }
dayTime, err := time.ParseInLocation("2006-01-02", day, location) dayTime, err := time.ParseInLocation("2006-01-02", day, location)
if err != nil { if err != nil {
continue continue
} }
allIDs := []int64{} // 三个 offset 的 cohort 用户并集只查一次活跃集合,把每天的 Mongo 点查从 3 次收敛到 1 次。
for _, userIDs := range byCountry { cohortsByOffset := make([]map[string][]int64, len(offsets))
allIDs = append(allIDs, userIDs...) unionIDs := []int64{}
seen := map[int64]struct{}{}
for index, offset := range offsets {
byCountry := cohortFor(dashboardSQLDate(dayTime.AddDate(0, 0, -offset.days)))
cohortsByOffset[index] = byCountry
for _, userIDs := range byCountry {
for _, userID := range userIDs {
if _, ok := seen[userID]; !ok {
seen[userID] = struct{}{}
unionIDs = append(unionIDs, userID)
}
}
}
} }
if len(allIDs) == 0 { if len(unionIDs) == 0 {
continue continue
} }
for _, offset := range offsets { activeSet, err := s.activeUserSet(ctx, day, unionIDs)
target := dashboardSQLDate(dayTime.AddDate(0, 0, offset.days)) if err != nil {
if target > today { return err
continue }
} for index, offset := range offsets {
activeSet, err := s.loadActiveUserSet(ctx, target, allIDs) for countryCode, userIDs := range cohortsByOffset[index] {
if err != nil { if len(userIDs) == 0 {
return err continue
} }
for countryCode, userIDs := range byCountry {
active := int64(0) active := int64(0)
for _, userID := range userIDs { for _, userID := range userIDs {
if _, ok := activeSet[userID]; ok { if _, ok := activeSet[userID]; ok {

View File

@ -391,3 +391,78 @@ func assertMetricSourceAvailable(t *testing.T, sources []map[string]any, field s
} }
t.Fatalf("metric source %s not found in %#v", field, sources) t.Fatalf("metric source %s not found in %#v", field, sources)
} }
// applyRetention 的观察日回看归属:区间内每天 D 的 DN 计数 = D-N 注册 cohort 在 D 的活跃,
// 归属观察日 D主 cohorts 与补载的 earlier cohorts 同日并存时必须合并;无 cohort 的天基数保持 0。
func TestAslanMongoApplyRetentionAttributesToObservationDay(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
startDate := time.Date(2026, 6, 10, 0, 0, 0, 0, location)
endDate := time.Date(2026, 6, 12, 0, 0, 0, 0, location)
grid := newLegacyMongoGrid(startDate, endDate)
// 主 cohorts 覆盖 [start, end)"2026-06-10" 同时出现在 earlier 里,验证合并。
cohorts := map[string]map[string][]int64{
"2026-06-10": {"SA": {1, 2}},
"2026-06-11": {"EG": {3}},
}
earlier := map[string]map[string][]int64{
"2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort
"2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort
"2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort
"2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort
"2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50}
}
activeByDay := map[string]map[int64]struct{}{
"2026-06-10": {10: {}, 20: {}, 30: {}},
"2026-06-11": {1: {}, 50: {}},
}
var cohortRangeFrom, cohortRangeTo string
requestedUnions := map[string]int{}
source := &AslanMongoDashboardSource{
statTimezone: "Asia/Shanghai",
retentionCohortsLoader: func(_ context.Context, from time.Time, to time.Time, _ externalDashboardCountryFilter) (map[string]map[string][]int64, error) {
cohortRangeFrom, cohortRangeTo = dashboardSQLDate(from), dashboardSQLDate(to)
return earlier, nil
},
activeUserSetLoader: func(_ context.Context, activeDate string, userIDs []int64) (map[int64]struct{}, error) {
requestedUnions[activeDate] = len(userIDs)
return activeByDay[activeDate], nil
},
}
if err := source.applyRetention(context.Background(), startDate, externalDashboardCountryFilter{}, cohorts, grid); err != nil {
t.Fatalf("apply retention: %v", err)
}
if cohortRangeFrom != "2026-05-11" || cohortRangeTo != "2026-06-10" {
t.Fatalf("expected earlier cohort range [start-30, start), got [%s, %s)", cohortRangeFrom, cohortRangeTo)
}
// 每个观察日只做一次活跃点查,且是三个 offset cohort 的并集。
// 06-10: D1{10,11}D7{20,21,22}D30{30}=606-11: D1 合并 cohort{1,2,50}D7{40}=4。
if requestedUnions["2026-06-10"] != 6 || requestedUnions["2026-06-11"] != 4 {
t.Fatalf("expected union sizes 6/4, got %#v", requestedUnions)
}
day1SA := grid.at("2026-06-10", "SA")
if day1SA.D1Users != 1 || day1SA.D1Base != 2 || day1SA.D7Users != 1 || day1SA.D7Base != 3 {
t.Fatalf("obs 06-10 SA retention mismatch: %+v", day1SA)
}
day1EG := grid.at("2026-06-10", "EG")
if day1EG.D30Users != 1 || day1EG.D30Base != 1 {
t.Fatalf("obs 06-10 EG d30 mismatch: %+v", day1EG)
}
day2SA := grid.at("2026-06-11", "SA")
if day2SA.D1Users != 2 || day2SA.D1Base != 3 {
t.Fatalf("obs 06-11 SA d1 (merged cohort) mismatch: %+v", day2SA)
}
day2EG := grid.at("2026-06-11", "EG")
if day2EG.D7Users != 0 || day2EG.D7Base != 1 {
t.Fatalf("obs 06-11 EG d7 real-zero mismatch: %+v", day2EG)
}
if day2EG.D30Base != 0 || day2SA.D30Base != 0 {
t.Fatalf("obs 06-11 d30 should have no cohort: SA=%+v EG=%+v", day2SA, day2EG)
}
}

View File

@ -6,11 +6,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { func RegisterRoutes(appProtected *gin.RouterGroup, workspaceProtected *gin.RouterGroup, h *Handler) {
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview) // 主后台画像严格跟随用户 App scope独立运营工作台继续使用自身的查询 App 和数据范围,允许零主站 App 用户进入通用入口。
protected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview) appProtected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview) appProtected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview)
protected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers) workspaceProtected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
protected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords) workspaceProtected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
protected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview) workspaceProtected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)
workspaceProtected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
} }

View File

@ -114,12 +114,10 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
// login_audit 的复合索引把 login_type 放在 created_at_ms 之前;若把三种登录类型写成 IN // login_audit 的复合索引把 login_type 放在 created_at_ms 之前;若把三种登录类型写成 IN
// MySQL 必须为每个用户扫描并排序全部候选,线上百万级审计表会超时。这里按类型拆成三个等值索引 // MySQL 必须为每个用户扫描并排序全部候选,线上百万级审计表会超时。这里按类型拆成三个等值索引
// 范围,每个范围只取一条,再对最多三条候选按 (created_at_ms, id) 选最新记录,既保持原口径, // 范围,每个范围只取一条,再对最多三条候选按 (created_at_ms, id) 选最新记录,既保持原口径,
// 也避免窗口函数物化整张审计表。natural 是 MySQL 保留字,用户别名必须使用 natural_user。 // 也避免窗口函数物化整张审计表。版本过滤必须放在 latest 选定之后:若先过滤审计行,最新登录
// 没带版本的用户会错误回退到旧版本。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
rows, err := s.userDB.QueryContext(ctx, ` rows, err := s.userDB.QueryContext(ctx, `
SELECT CASE SELECT TRIM(latest.app_version) AS version_key,
WHEN latest.app_version IS NULL OR TRIM(latest.app_version) = '' THEN 'unknown'
ELSE TRIM(latest.app_version)
END AS version_key,
COUNT(*) AS user_count COUNT(*) AS user_count
FROM users AS natural_user FROM users AS natural_user
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@ -161,6 +159,9 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
WHERE natural_user.app_code = ? WHERE natural_user.app_code = ?
AND natural_user.profile_completed = 1 AND natural_user.profile_completed = 1
AND LOWER(TRIM(COALESCE(natural_user.source, ''))) NOT IN ('game_robot', 'quick_account') AND LOWER(TRIM(COALESCE(natural_user.source, ''))) NOT IN ('game_robot', 'quick_account')
AND latest.app_version IS NOT NULL
AND TRIM(latest.app_version) <> ''
AND LOWER(TRIM(latest.app_version)) NOT IN ('unknown', '<nil>')
GROUP BY version_key GROUP BY version_key
ORDER BY user_count DESC, version_key ASC`, appCode) ORDER BY user_count DESC, version_key ASC`, appCode)
if err != nil { if err != nil {
@ -176,7 +177,11 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
return nil, err return nil, err
} }
key = normalizeDistributionKey(key) key = normalizeDistributionKey(key)
items = append(items, UserProfileDistributionItem{Key: key, Label: appVersionDistributionLabel(key), Count: count}) // SQL 已排除所有无版本用户;这里仍守住响应边界,避免测试替身或历史脏值把“未知版本”带回前端。
if key == "unknown" {
continue
}
items = append(items, UserProfileDistributionItem{Key: key, Label: key, Count: count})
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -206,10 +211,3 @@ func genderDistributionLabel(key string) string {
return key return key
} }
} }
func appVersionDistributionLabel(key string) string {
if key == "unknown" {
return "未知版本"
}
return key
}

View File

@ -50,6 +50,8 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
('lalu', 6, 'male', 1, 'game_robot'), ('lalu', 6, 'male', 1, 'game_robot'),
('lalu', 7, 'female', 0, 'third_party'), ('lalu', 7, 'female', 0, 'third_party'),
('lalu', 9, 'female', 1, 'third_party'), ('lalu', 9, 'female', 1, 'third_party'),
('lalu', 10, 'male', 1, 'password'),
('lalu', 11, 'female', 1, 'password'),
('huwaa', 1, 'female', 1, 'third_party')`, ('huwaa', 1, 'female', 1, 'third_party')`,
`INSERT INTO login_audit (app_code, user_id, login_type, app_version, result, blocked, created_at_ms) VALUES `INSERT INTO login_audit (app_code, user_id, login_type, app_version, result, blocked, created_at_ms) VALUES
('lalu', 1, 'password', '1.0.0', 'success', 0, 100), ('lalu', 1, 'password', '1.0.0', 'success', 0, 100),
@ -65,6 +67,8 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
('lalu', 6, 'password', 'excluded', 'success', 0, 500), ('lalu', 6, 'password', 'excluded', 'success', 0, 500),
('lalu', 7, 'password', 'excluded', 'success', 0, 500), ('lalu', 7, 'password', 'excluded', 'success', 0, 500),
('lalu', 9, 'password', ' ', 'success', 0, 500), ('lalu', 9, 'password', ' ', 'success', 0, 500),
('lalu', 10, 'password', 'unknown', 'success', 0, 500),
('lalu', 11, 'password', '<nil>', 'success', 0, 500),
('huwaa', 1, 'password', 'other-app', 'success', 0, 500)`, ('huwaa', 1, 'password', 'other-app', 'success', 0, 500)`,
) )
@ -74,10 +78,9 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("query real MySQL version distribution: %v", err) t.Fatalf("query real MySQL version distribution: %v", err)
} }
if len(items) != 3 || if len(items) != 2 ||
items[0] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) || items[0] != (UserProfileDistributionItem{Key: "3.0.0", Label: "3.0.0", Count: 1}) ||
items[1] != (UserProfileDistributionItem{Key: "3.0.0", Label: "3.0.0", Count: 1}) || items[1] != (UserProfileDistributionItem{Key: "time-wins", Label: "time-wins", Count: 1}) {
items[2] != (UserProfileDistributionItem{Key: "time-wins", Label: "time-wins", Count: 1}) {
t.Fatalf("version distribution mismatch: %+v", items) t.Fatalf("version distribution mismatch: %+v", items)
} }
} }

View File

@ -29,7 +29,7 @@ func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.
AddRow("male", int64(4)). AddRow("male", int64(4)).
AddRow("female", int64(2)). AddRow("female", int64(2)).
AddRow("unknown", int64(1))) AddRow("unknown", int64(1)))
sqlMock.ExpectQuery(`(?s)FROM users AS natural_user.*LEFT JOIN LATERAL.*login_type = 'password'.*ORDER BY audit.created_at_ms DESC, audit.id DESC.*UNION ALL.*login_type = 'third_party'.*UNION ALL.*login_type = 'refresh'.*ORDER BY candidate.created_at_ms DESC, candidate.id DESC.*WHERE natural_user.app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*GROUP BY version_key`). sqlMock.ExpectQuery(`(?s)FROM users AS natural_user.*LEFT JOIN LATERAL.*login_type = 'password'.*ORDER BY audit.created_at_ms DESC, audit.id DESC.*UNION ALL.*login_type = 'third_party'.*UNION ALL.*login_type = 'refresh'.*ORDER BY candidate.created_at_ms DESC, candidate.id DESC.*WHERE natural_user.app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*latest.app_version IS NOT NULL.*TRIM\(latest.app_version\) <> ''.*NOT IN \('unknown', '<nil>'\).*GROUP BY version_key`).
WithArgs("huwaa"). WithArgs("huwaa").
WillReturnRows(sqlmock.NewRows([]string{"version_key", "user_count"}). WillReturnRows(sqlmock.NewRows([]string{"version_key", "user_count"}).
AddRow("2.4.0", int64(4)). AddRow("2.4.0", int64(4)).
@ -84,12 +84,11 @@ func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.
envelope.Data.GenderDistribution[2] != (UserProfileDistributionItem{Key: "unknown", Label: "未知", Count: 1}) { envelope.Data.GenderDistribution[2] != (UserProfileDistributionItem{Key: "unknown", Label: "未知", Count: 1}) {
t.Fatalf("gender distribution mismatch: %+v", envelope.Data.GenderDistribution) t.Fatalf("gender distribution mismatch: %+v", envelope.Data.GenderDistribution)
} }
if len(envelope.Data.AppVersionDistribution) != 2 || if len(envelope.Data.AppVersionDistribution) != 1 ||
envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) || envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) {
envelope.Data.AppVersionDistribution[1] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) {
t.Fatalf("version distribution mismatch: %+v", envelope.Data.AppVersionDistribution) t.Fatalf("version distribution mismatch: %+v", envelope.Data.AppVersionDistribution)
} }
// 版本分布以全部自然用户为分母并直接查询 login_auditstatistics-service 只保留新增/DAU 口径。 // 版本分布直接查询 login_audit 且只返回有最近登录版本的自然用户statistics-service 只保留新增/DAU 口径。
if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] { if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] {
t.Fatalf("statistics calls mismatch: %+v", requested) t.Fatalf("statistics calls mismatch: %+v", requested)
} }
@ -146,7 +145,7 @@ func TestUserProfileOverviewRecordsInternalErrorAgainstRequestID(t *testing.T) {
recordedErrors = c.Errors.String() recordedErrors = c.Errors.String()
}) })
protected := router.Group("/api/v1") protected := router.Group("/api/v1")
RegisterRoutes(protected, NewWithService(service)) RegisterRoutes(protected, protected, NewWithService(service))
request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/user-profile-overview", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/user-profile-overview", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@ -173,6 +172,6 @@ func newDashboardUserProfileTestRouter(handler *Handler, permissions []string) *
c.Next() c.Next()
}) })
protected := router.Group("/api/v1") protected := router.Group("/api/v1")
RegisterRoutes(protected, handler) RegisterRoutes(protected, protected, handler)
return router return router
} }

View File

@ -42,6 +42,7 @@ type LegacyRegionCatalogSource interface {
type LegacyAppDescriptor struct { type LegacyAppDescriptor struct {
AppCode string AppCode string
AppName string AppName string
LogoURL string
} }
type Service struct { type Service struct {
@ -152,6 +153,7 @@ func NewService(store *repository.Store, dashboards *dashboard.DashboardService,
type AppInfo struct { type AppInfo struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
AppName string `json:"app_name"` AppName string `json:"app_name"`
LogoURL string `json:"logo_url"`
Kind string `json:"kind"` Kind string `json:"kind"`
} }
@ -1172,6 +1174,7 @@ func (s *Service) listApps(ctx context.Context) ([]AppInfo, error) {
out = append(out, AppInfo{ out = append(out, AppInfo{
AppCode: appCode, AppCode: appCode,
AppName: firstNonEmptyString(app.AppName, appCode), AppName: firstNonEmptyString(app.AppName, appCode),
LogoURL: strings.TrimSpace(app.LogoURL),
Kind: appKindHyapp, Kind: appKindHyapp,
}) })
} }
@ -1184,6 +1187,7 @@ func (s *Service) listApps(ctx context.Context) ([]AppInfo, error) {
out = append(out, AppInfo{ out = append(out, AppInfo{
AppCode: app.AppCode, AppCode: app.AppCode,
AppName: firstNonEmptyString(app.AppName, app.AppCode), AppName: firstNonEmptyString(app.AppName, app.AppCode),
LogoURL: strings.TrimSpace(app.LogoURL),
Kind: appKindLegacy, Kind: appKindLegacy,
}) })
} }

View File

@ -1,6 +1,7 @@
package databi package databi
import ( import (
"context"
"testing" "testing"
"time" "time"
@ -8,6 +9,22 @@ import (
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
) )
func TestListAppsPreservesLegacyLogoURL(t *testing.T) {
service := NewService(nil, nil, nil, nil, WithLegacyApps(LegacyAppDescriptor{
AppCode: "Aslan",
AppName: "Aslan",
LogoURL: "https://media.example.com/aslan.png",
}))
apps, err := service.listApps(context.Background())
if err != nil {
t.Fatalf("list apps failed: %v", err)
}
if len(apps) != 1 || apps[0].AppCode != "aslan" || apps[0].LogoURL != "https://media.example.com/aslan.png" {
t.Fatalf("legacy app logo mismatch: %+v", apps)
}
}
func testAccess() repository.MoneyAccess { func testAccess() repository.MoneyAccess {
return repository.MoneyAccess{ return repository.MoneyAccess{
UserID: 7, UserID: 7,

View File

@ -1,119 +0,0 @@
package financeapplication
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/integration/dingtalk"
)
type dingTalkNotifier struct {
client *dingtalk.Client
}
func NewDingTalkNotifier(client *dingtalk.Client) ApplicationNotifier {
if client == nil {
return nil
}
return &dingTalkNotifier{client: client}
}
func (n *dingTalkNotifier) NotifyApplicationCreated(ctx context.Context, application applicationDTO) error {
if n == nil || n.client == nil {
return nil
}
return n.client.SendMarkdown(ctx, dingtalk.MarkdownMessage{
Title: "财务申请待审核",
Text: financeApplicationCreatedMarkdown(application),
})
}
func financeApplicationCreatedMarkdown(application applicationDTO) string {
lines := []string{
"### 财务申请待审核",
"",
fmt.Sprintf("- 申请ID%d", application.ID),
fmt.Sprintf("- APP%s", markdownValue(application.AppCode)),
fmt.Sprintf("- 操作:%s", markdownValue(operationLabel(application.Operation))),
fmt.Sprintf("- 目标用户ID%s", markdownValue(application.TargetUserID)),
fmt.Sprintf("- 金币数量:%d", application.CoinAmount),
fmt.Sprintf("- 充值金额 $ %s", markdownValue(application.RechargeAmount)),
fmt.Sprintf("- 申请人:%s", markdownValue(actorLabel(application.ApplicantName, application.ApplicantUserID))),
fmt.Sprintf("- 发起时间:%s", markdownValue(formatNotifyMillis(application.CreatedAtMS))),
"- 后台地址https://admin-acc.global-interaction.com/finance/",
}
if application.WalletIdentity != "" {
lines = append(lines, fmt.Sprintf("- 钱包身份:%s", markdownValue(walletIdentityLabel(application.WalletIdentity))))
}
if application.CredentialText != "" {
lines = append(lines, fmt.Sprintf("- 凭证文字:%s", markdownValue(application.CredentialText)))
}
if application.CredentialImageURL != "" {
imageURL := strings.TrimSpace(application.CredentialImageURL)
lines = append(lines, "- 凭证图片:", fmt.Sprintf("![凭证图片](%s)", imageURL))
}
return strings.Join(lines, "\n")
}
func operationLabel(value string) string {
switch value {
case "user_coin_credit":
return "增加用户金币"
case "user_coin_debit":
return "减少用户金币"
case "user_wallet_debit":
return "用户钱包扣减"
case "user_wallet_credit":
return "用户钱包增加"
case "coin_seller_coin_credit":
return "增加币商金币"
case "coin_seller_coin_debit":
return "减少币商金币"
default:
return value
}
}
func walletIdentityLabel(value string) string {
switch value {
case "host":
return "host"
case "agency":
return "agency"
case "bd":
return "bd"
default:
return value
}
}
func actorLabel(name string, userID uint) string {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Sprintf("%d", userID)
}
return fmt.Sprintf("%s(%d)", name, userID)
}
func formatNotifyMillis(value int64) string {
if value <= 0 {
return "-"
}
// 财务审批群主要按北京时间协作;数据库仍保存 Unix 毫秒,这里只在通知文本中转换展示。
return time.UnixMilli(value).In(time.FixedZone("UTC+8", 8*60*60)).Format("2006-01-02 15:04:05")
}
func markdownValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "-"
}
if len([]rune(value)) > 240 {
runes := []rune(value)
value = string(runes[:240]) + "..."
}
replacer := strings.NewReplacer("\r", " ", "\n", " ", "\t", " ")
return replacer.Replace(value)
}

View File

@ -1,42 +0,0 @@
package financeapplication
import (
"strings"
"testing"
"time"
)
func TestFinanceApplicationCreatedMarkdown(t *testing.T) {
text := financeApplicationCreatedMarkdown(applicationDTO{
ID: 12,
AppCode: "lalu",
Operation: "coin_seller_coin_credit",
TargetUserID: "10001",
CoinAmount: 1000,
RechargeAmount: "10.50",
CredentialImageURL: "https://example.com/receipt.png",
CredentialText: "bank order\nabc",
ApplicantUserID: 7,
ApplicantName: "ops",
CreatedAtMS: time.Date(2026, 7, 1, 1, 2, 3, 0, time.UTC).UnixMilli(),
})
for _, want := range []string{
"### 财务申请待审核",
"- APPlalu",
"- 操作:增加币商金币",
"- 目标用户ID10001",
"- 金币数量1000",
"- 充值金额 $ 10.50",
"- 申请人ops(7)",
"- 发起时间2026-07-01 09:02:03",
"- 后台地址https://admin-acc.global-interaction.com/finance/",
"- 凭证文字bank order abc",
"- 凭证图片:",
"![凭证图片](https://example.com/receipt.png)",
} {
if !strings.Contains(text, want) {
t.Fatalf("markdown missing %q:\n%s", want, text)
}
}
}

View File

@ -1,180 +0,0 @@
package financeapplication
import (
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
func New(store *repository.Store, audit shared.OperationLogger, options ...Option) *Handler {
return &Handler{service: NewService(store, options...), audit: audit}
}
func (h *Handler) ListApplications(c *gin.Context) {
options := shared.ListOptions(c)
items, total, err := h.service.ListApplications(repository.FinanceApplicationListOptions{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"),
Operation: firstQuery(c, "operation"),
Status: options.Status,
Keyword: options.Keyword,
})
if err != nil {
response.ServerError(c, "获取财务申请列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) ListMyApplications(c *gin.Context) {
options := shared.ListOptions(c)
items, total, err := h.service.ListMyApplications(shared.ActorFromContext(c), repository.FinanceApplicationListOptions{
Page: options.Page,
PageSize: options.PageSize,
AppCode: firstQuery(c, "app_code", "appCode"),
Operation: firstQuery(c, "operation"),
Status: options.Status,
Keyword: options.Keyword,
})
if err != nil {
writeServiceError(c, err, "获取我的财务申请列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
func (h *Handler) CreateApplication(c *gin.Context) {
var req createApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "财务申请参数不正确")
return
}
item, err := h.service.CreateApplication(c.Request.Context(), shared.ActorFromContext(c), req)
if err != nil {
writeServiceError(c, err, "发起财务申请失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-finance-application", "admin_finance_applications", idString(item.ID), "success", item.Operation)
response.Created(c, item)
}
func (h *Handler) ApproveApplication(c *gin.Context) {
h.auditApplication(c, model.FinanceApplicationStatusApproved)
}
func (h *Handler) RejectApplication(c *gin.Context) {
h.auditApplication(c, model.FinanceApplicationStatusRejected)
}
func (h *Handler) AuditApplication(c *gin.Context) {
var req struct {
Decision string `json:"decision"`
AuditRemark string `json:"auditRemark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "审核参数不正确")
return
}
switch strings.TrimSpace(req.Decision) {
case model.FinanceApplicationStatusApproved:
h.auditApplicationWithRemark(c, model.FinanceApplicationStatusApproved, req.AuditRemark)
case model.FinanceApplicationStatusRejected:
h.auditApplicationWithRemark(c, model.FinanceApplicationStatusRejected, req.AuditRemark)
default:
response.BadRequest(c, "审核结果不正确")
}
}
func (h *Handler) auditApplication(c *gin.Context, decision string) {
var req auditApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "审核参数不正确")
return
}
h.auditApplicationWithRemark(c, decision, req.AuditRemark)
}
func (h *Handler) auditApplicationWithRemark(c *gin.Context, decision string, remark string) {
id, ok := shared.ParseID(c, "application_id")
if !ok {
return
}
var (
item *applicationDTO
err error
)
if decision == model.FinanceApplicationStatusApproved {
item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, remark, middleware.CurrentRequestID(c))
} else {
item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, remark, middleware.CurrentRequestID(c))
}
if err != nil {
writeServiceError(c, err, "审核财务申请失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "audit-finance-application", "admin_finance_applications", idString(item.ID), "success", decision)
response.OK(c, item)
}
func writeServiceError(c *gin.Context, err error, fallback string) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.NotFound(c, "财务申请不存在")
return
}
if errors.Is(err, repository.ErrFinanceApplicationAlreadyAudited) {
response.BadRequest(c, "申请已审核")
return
}
switch err.Error() {
case "没有操作权限":
response.Forbidden(c, err.Error())
case "admin store is not configured", "finance wallet executor is not configured":
response.ServerError(c, fallback)
default:
if status.Code(err) == codes.NotFound {
response.NotFound(c, err.Error())
return
}
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {
switch st.Code() {
case codes.PermissionDenied:
response.Forbidden(c, st.Message())
return
case codes.InvalidArgument, codes.FailedPrecondition, codes.AlreadyExists, codes.Aborted, codes.ResourceExhausted:
response.BadRequest(c, st.Message())
return
}
}
response.BadRequest(c, err.Error())
}
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
func idString(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}

View File

@ -1,16 +0,0 @@
package financeapplication
type createApplicationRequest struct {
AppCode string `json:"appCode"`
Operation string `json:"operation"`
WalletIdentity string `json:"walletIdentity"`
TargetUserID string `json:"targetUserId"`
CoinAmount int64 `json:"coinAmount"`
RechargeAmount float64 `json:"rechargeAmount"`
CredentialImageURL string `json:"credentialImageUrl"`
CredentialText string `json:"credentialText"`
}
type auditApplicationRequest struct {
AuditRemark string `json:"auditRemark"`
}

View File

@ -1,68 +0,0 @@
package financeapplication
import "hyapp-admin-server/internal/model"
type applicationDTO struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
Operation string `json:"operation"`
WalletIdentity string `json:"walletIdentity"`
TargetUserID string `json:"targetUserId"`
CoinAmount int64 `json:"coinAmount"`
RechargeAmount string `json:"rechargeAmount"`
CredentialImageURL string `json:"credentialImageUrl"`
CredentialText string `json:"credentialText"`
ApplicantUserID uint `json:"applicantUserId"`
ApplicantName string `json:"applicantName"`
AuditorUserID *uint `json:"auditorUserId"`
AuditorName string `json:"auditorName"`
Status string `json:"status"`
AuditRemark string `json:"auditRemark"`
AuditedAtMS *int64 `json:"auditedAtMs"`
WalletCommandID string `json:"walletCommandId"`
WalletTransactionID string `json:"walletTransactionId"`
WalletAssetType string `json:"walletAssetType"`
WalletAmountDelta int64 `json:"walletAmountDelta"`
WalletBalanceAfter int64 `json:"walletBalanceAfter"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
DingTalkNotified *bool `json:"dingTalkNotified,omitempty"`
}
func applicationDTOFromModel(item model.FinanceApplication) applicationDTO {
return applicationDTO{
ID: item.ID,
AppCode: item.AppCode,
AppName: item.AppCode,
Operation: item.Operation,
WalletIdentity: item.WalletIdentity,
TargetUserID: item.TargetUserID,
CoinAmount: item.CoinAmount,
RechargeAmount: item.RechargeAmount,
CredentialImageURL: item.CredentialImageURL,
CredentialText: item.CredentialText,
ApplicantUserID: item.ApplicantUserID,
ApplicantName: item.ApplicantName,
AuditorUserID: item.AuditorUserID,
AuditorName: item.AuditorName,
Status: item.Status,
AuditRemark: item.AuditRemark,
AuditedAtMS: item.AuditedAtMS,
WalletCommandID: item.WalletCommandID,
WalletTransactionID: item.WalletTransactionID,
WalletAssetType: item.WalletAssetType,
WalletAmountDelta: item.WalletAmountDelta,
WalletBalanceAfter: item.WalletBalanceAfter,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
}
func applicationDTOsFromModel(items []model.FinanceApplication) []applicationDTO {
out := make([]applicationDTO, 0, len(items))
for _, item := range items {
out = append(out, applicationDTOFromModel(item))
}
return out
}

View File

@ -1,19 +0,0 @@
package financeapplication
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/finance/applications", middleware.RequirePermission(permissionAuditApplication), h.ListApplications)
protected.GET("/admin/finance/applications/mine", middleware.RequirePermission(permissionCreateApplication), h.ListMyApplications)
protected.POST("/admin/finance/applications", middleware.RequirePermission(permissionCreateApplication), h.CreateApplication)
protected.POST("/admin/finance/applications/:application_id/approve", middleware.RequirePermission(permissionAuditApplication), h.ApproveApplication)
protected.POST("/admin/finance/applications/:application_id/reject", middleware.RequirePermission(permissionAuditApplication), h.RejectApplication)
protected.POST("/admin/finance/applications/:application_id/audit", middleware.RequirePermission(permissionAuditApplication), h.AuditApplication)
}

View File

@ -1,623 +0,0 @@
package financeapplication
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
walletv1 "hyapp.local/api/proto/wallet/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
permissionCreateApplication = "finance-application:create"
permissionAuditApplication = "finance-application:audit"
financeInternalUserIDMinDigits = 15
financeAssetCoin = "COIN"
financeAssetCoinSellerCoin = "COIN_SELLER_COIN"
financeAssetHostSalaryUSD = "HOST_SALARY_USD"
financeAssetAgencySalaryUSD = "AGENCY_SALARY_USD"
financeAssetBDSalaryUSD = "BD_SALARY_USD"
financeStockTypeUSDTPurchase = "usdt_purchase"
financeStockTypeUSDTDeduction = "usdt_deduction"
)
var (
errInvalidCreateInput = errors.New("财务申请参数不正确")
errInvalidAuditInput = errors.New("审核参数不正确")
)
type operationDefinition struct {
Value string
PermissionCode string
RequiresWalletIdentity bool
AllowsZeroRechargeAmount bool
}
type Service struct {
store *repository.Store
wallet walletclient.Client
user userclient.Client
now func() time.Time
notifier ApplicationNotifier
}
type Option func(*Service)
type ApplicationNotifier interface {
NotifyApplicationCreated(ctx context.Context, application applicationDTO) error
}
func WithWalletClient(wallet walletclient.Client) Option {
return func(service *Service) {
service.wallet = wallet
}
}
func WithUserClient(user userclient.Client) Option {
return func(service *Service) {
service.user = user
}
}
func WithApplicationNotifier(notifier ApplicationNotifier) Option {
return func(service *Service) {
service.notifier = notifier
}
}
func NewService(store *repository.Store, options ...Option) *Service {
service := &Service{store: store, now: func() time.Time { return time.Now().UTC() }}
for _, option := range options {
if option != nil {
option(service)
}
}
return service
}
func (s *Service) CreateApplication(ctx context.Context, actor shared.Actor, req createApplicationRequest) (*applicationDTO, error) {
if s == nil || s.store == nil {
return nil, errors.New("admin store is not configured")
}
input, err := s.normalizeCreateInput(actor, req)
if err != nil {
return nil, err
}
if err := s.store.CreateFinanceApplication(&input); err != nil {
return nil, err
}
item, err := s.store.GetFinanceApplication(input.ID)
if err != nil {
return nil, err
}
dto := applicationDTOFromModel(*item)
notified := false
if s.notifier != nil {
// 申请落库后再通知财务群;通知失败只影响提醒,不回滚申请,避免外部机器人抖动造成业务申请丢失。
if err := s.notifier.NotifyApplicationCreated(ctx, dto); err != nil {
slog.Warn("finance_application_dingtalk_notify_failed", "application_id", dto.ID, "app_code", dto.AppCode, "target_user_id", dto.TargetUserID, "error", err)
} else {
notified = true
}
}
dto.DingTalkNotified = &notified
return &dto, nil
}
func (s *Service) ListApplications(options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
items, total, err := s.store.ListFinanceApplications(options)
if err != nil {
return nil, 0, err
}
return applicationDTOsFromModel(items), total, nil
}
func (s *Service) ListMyApplications(actor shared.Actor, options repository.FinanceApplicationListOptions) ([]applicationDTO, int64, error) {
if s == nil || s.store == nil {
return nil, 0, errors.New("admin store is not configured")
}
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCreateApplication) {
return nil, 0, errors.New("没有操作权限")
}
// 申请人范围必须在服务端写入查询条件;前端只传列表筛选项,不能决定 applicant_user_id。
options.ApplicantUserID = actor.UserID
items, total, err := s.store.ListFinanceApplications(options)
if err != nil {
return nil, 0, err
}
return applicationDTOsFromModel(items), total, nil
}
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusApproved, remark, requestID)
}
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*applicationDTO, error) {
return s.auditApplication(ctx, actor, id, model.FinanceApplicationStatusRejected, remark, requestID)
}
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, requestID string) (*applicationDTO, error) {
if s == nil || s.store == nil {
return nil, errors.New("admin store is not configured")
}
if id == 0 || !hasPermission(actor.Permissions, permissionAuditApplication) {
return nil, errInvalidAuditInput
}
execution := walletExecutionResult{}
if decision == model.FinanceApplicationStatusApproved {
application, err := s.store.GetFinanceApplication(id)
if err != nil {
return nil, err
}
if application.Status != model.FinanceApplicationStatusPending {
return nil, repository.ErrFinanceApplicationAlreadyAudited
}
execution, err = s.executeApprovedApplication(ctx, actor, *application, requestID)
if err != nil {
return nil, err
}
}
item, err := s.store.AuditFinanceApplication(id, repository.FinanceApplicationAuditInput{
Decision: decision,
AuditorUserID: actor.UserID,
AuditorName: actor.Username,
AuditRemark: strings.TrimSpace(remark),
AuditedAtMS: s.now().UnixMilli(),
WalletCommandID: execution.CommandID,
WalletTransactionID: execution.TransactionID,
WalletAssetType: execution.AssetType,
WalletAmountDelta: execution.AmountDelta,
WalletBalanceAfter: execution.BalanceAfter,
})
if err != nil {
return nil, err
}
dto := applicationDTOFromModel(*item)
return &dto, nil
}
type walletExecutionResult struct {
CommandID string
TransactionID string
AssetType string
AmountDelta int64
BalanceAfter int64
}
func (s *Service) executeApprovedApplication(ctx context.Context, actor shared.Actor, application model.FinanceApplication, requestID string) (walletExecutionResult, error) {
if s.wallet == nil || s.user == nil {
return walletExecutionResult{}, errors.New("finance wallet executor is not configured")
}
if actor.UserID == 0 {
return walletExecutionResult{}, errors.New("没有操作权限")
}
appCode := appctx.Normalize(application.AppCode)
ctx = appctx.WithContext(ctx, appCode)
target, err := s.resolveTargetUser(ctx, strings.TrimSpace(requestID), application.TargetUserID)
if err != nil {
return walletExecutionResult{}, err
}
commandID := financeWalletCommandID(application.ID, application.Operation)
reason := financeWalletReason(application)
evidenceRef := financeWalletEvidenceRef(application.ID)
switch application.Operation {
case "user_coin_credit", "user_coin_debit":
// 普通用户金币只落 COIN 资产;扣减用带符号金额交给 wallet-service 做余额不透支校验。
amount := signedAmount(application.CoinAmount, application.Operation == "user_coin_debit")
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, financeAssetCoin, amount, int64(actor.UserID), reason, evidenceRef)
case "user_wallet_credit", "user_wallet_debit":
assetType, err := financeSalaryAssetType(application.WalletIdentity)
if err != nil {
return walletExecutionResult{}, err
}
if err := s.requireWalletIdentity(ctx, strings.TrimSpace(requestID), target.UserID, application.WalletIdentity); err != nil {
return walletExecutionResult{}, err
}
amountMinor, err := decimalAmountMinor(application.RechargeAmount)
if err != nil {
return walletExecutionResult{}, err
}
// 身份钱包是美元工资资产,单位使用 wallet-service 现有的 minor 口径;金币数量不参与这类调账。
amount := signedAmount(amountMinor, application.Operation == "user_wallet_debit")
return s.adminAdjustAsset(ctx, commandID, appCode, target.UserID, assetType, amount, int64(actor.UserID), reason, evidenceRef)
case "coin_seller_coin_credit", "coin_seller_coin_debit":
if err := s.requireActiveCoinSeller(ctx, strings.TrimSpace(requestID), target); err != nil {
return walletExecutionResult{}, err
}
paidAmountMicro, err := decimalAmountMicro(application.RechargeAmount)
if err != nil {
return walletExecutionResult{}, err
}
stockType := financeStockType(application.Operation)
resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: target.UserID,
StockType: stockType,
CoinAmount: application.CoinAmount,
PaidCurrencyCode: "USDT",
PaidAmountMicro: paidAmountMicro,
PaymentRef: evidenceRef,
EvidenceRef: evidenceRef,
OperatorUserId: int64(actor.UserID),
Reason: reason,
AppCode: appCode,
SellerCountryId: target.CountryID,
SellerRegionId: target.RegionID,
})
if err != nil {
return walletExecutionResult{}, err
}
return walletExecutionResult{
CommandID: commandID,
TransactionID: resp.GetTransactionId(),
AssetType: financeAssetCoinSellerCoin,
AmountDelta: resp.GetCoinAmount(),
BalanceAfter: resp.GetBalanceAfter(),
}, nil
default:
return walletExecutionResult{}, errors.New("财务操作不正确")
}
}
func (s *Service) adminAdjustAsset(ctx context.Context, commandID string, appCode string, targetUserID int64, assetType string, amount int64, operatorUserID int64, reason string, evidenceRef string) (walletExecutionResult, error) {
resp, err := s.wallet.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
CommandId: commandID,
TargetUserId: targetUserID,
AssetType: assetType,
Amount: amount,
OperatorUserId: operatorUserID,
Reason: reason,
EvidenceRef: evidenceRef,
AppCode: appCode,
})
if err != nil {
return walletExecutionResult{}, err
}
balanceAfter := int64(0)
if balance := resp.GetBalance(); balance != nil {
balanceAfter = balance.GetAvailableAmount()
}
return walletExecutionResult{
CommandID: commandID,
TransactionID: resp.GetTransactionId(),
AssetType: assetType,
AmountDelta: amount,
BalanceAfter: balanceAfter,
}, nil
}
func (s *Service) resolveTargetUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
keyword := strings.TrimSpace(rawTarget)
if keyword == "" {
return nil, errors.New("目标用户ID不正确")
}
numericID, numericOK := parsePositiveInt64(keyword)
userIDChecked := false
if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeInternalUserIDMinDigits {
userIDChecked = true
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
return user, err
}
}
if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
RequestID: requestID,
Caller: "admin-server",
DisplayUserID: keyword,
}); err != nil {
if !isGRPCNotFound(err) {
return nil, err
}
} else if identity != nil && identity.UserID > 0 {
user, found, err := s.getUserByID(ctx, requestID, identity.UserID)
if err != nil {
return nil, err
}
if found {
return user, nil
}
}
if numericOK && !userIDChecked {
if user, found, err := s.getUserByID(ctx, requestID, numericID); err != nil || found {
return user, err
}
}
return nil, errors.New("目标用户不存在")
}
func (s *Service) getUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) {
user, err := s.user.GetUser(ctx, userclient.GetUserRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: userID,
})
if err != nil {
if isGRPCNotFound(err) {
return nil, false, nil
}
return nil, false, err
}
if user == nil || user.UserID <= 0 {
return nil, false, nil
}
return user, true, nil
}
func (s *Service) requireWalletIdentity(ctx context.Context, requestID string, userID int64, walletIdentity string) error {
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: userID,
})
if err != nil {
return err
}
switch strings.TrimSpace(walletIdentity) {
case "host":
if summary != nil && summary.IsHost {
return nil
}
case "agency":
if summary != nil && summary.IsAgency {
return nil
}
case "bd":
if summary != nil && summary.IsBD {
return nil
}
}
return errors.New("目标用户没有对应钱包身份")
}
func (s *Service) requireActiveCoinSeller(ctx context.Context, requestID string, user *userclient.User) error {
if user == nil || user.UserID <= 0 {
return errors.New("目标用户不存在")
}
if user.CountryID <= 0 || user.RegionID <= 0 {
return errors.New("币商国家或区域不完整")
}
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: user.UserID,
})
if err != nil {
return err
}
if summary == nil || !summary.IsCoinSeller {
return errors.New("目标用户不是启用中的币商")
}
return nil
}
func (s *Service) normalizeCreateInput(actor shared.Actor, req createApplicationRequest) (model.FinanceApplication, error) {
appCode := strings.TrimSpace(req.AppCode)
operation := strings.TrimSpace(req.Operation)
targetUserID := strings.TrimSpace(req.TargetUserID)
credentialImageURL := strings.TrimSpace(req.CredentialImageURL)
credentialText := strings.TrimSpace(req.CredentialText)
definition, ok := financeOperationDefinitions()[operation]
if !ok || appCode == "" || targetUserID == "" || req.CoinAmount <= 0 || actor.UserID == 0 {
return model.FinanceApplication{}, errInvalidCreateInput
}
if !hasPermission(actor.Permissions, permissionCreateApplication) || !hasPermission(actor.Permissions, definition.PermissionCode) {
return model.FinanceApplication{}, errors.New("没有操作权限")
}
rechargeAmount, err := normalizeRechargeAmount(req.RechargeAmount, definition.AllowsZeroRechargeAmount)
if err != nil {
return model.FinanceApplication{}, err
}
walletIdentity := strings.TrimSpace(req.WalletIdentity)
if definition.RequiresWalletIdentity {
if !validWalletIdentity(walletIdentity) {
return model.FinanceApplication{}, errors.New("钱包身份不正确")
}
} else {
// 非钱包类申请不保存身份字段,避免后续审核或执行阶段把历史残留身份误当成业务参数。
walletIdentity = ""
}
if credentialImageURL == "" && credentialText == "" {
return model.FinanceApplication{}, errors.New("请上传凭证图片或填写凭证文字")
}
return model.FinanceApplication{
AppCode: appCode,
Operation: operation,
WalletIdentity: walletIdentity,
TargetUserID: targetUserID,
CoinAmount: req.CoinAmount,
RechargeAmount: rechargeAmount,
CredentialImageURL: credentialImageURL,
CredentialText: credentialText,
ApplicantUserID: actor.UserID,
ApplicantName: actor.Username,
Status: model.FinanceApplicationStatusPending,
}, nil
}
func financeOperationDefinitions() map[string]operationDefinition {
return map[string]operationDefinition{
"user_coin_credit": {
Value: "user_coin_credit",
PermissionCode: "finance-operation:user-coin-credit",
AllowsZeroRechargeAmount: true,
},
"user_coin_debit": {
Value: "user_coin_debit",
PermissionCode: "finance-operation:user-coin-debit",
AllowsZeroRechargeAmount: true,
},
"user_wallet_debit": {
Value: "user_wallet_debit",
PermissionCode: "finance-operation:user-wallet-debit",
RequiresWalletIdentity: true,
},
"user_wallet_credit": {
Value: "user_wallet_credit",
PermissionCode: "finance-operation:user-wallet-credit",
RequiresWalletIdentity: true,
},
"coin_seller_coin_credit": {
Value: "coin_seller_coin_credit",
PermissionCode: "finance-operation:coin-seller-coin-credit",
},
"coin_seller_coin_debit": {
Value: "coin_seller_coin_debit",
PermissionCode: "finance-operation:coin-seller-coin-debit",
},
}
}
func normalizeRechargeAmount(amount float64, allowZero bool) (string, error) {
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
return "", errors.New("充值金额不正确")
}
cents := int64(math.Round(amount * 100))
// 普通用户金币调账的执行金额来自 coin_amountrecharge_amount 只是凭证口径,允许记录 0身份钱包和币商进货会把它转换成真实 USD/USDT 金额,必须保持正数。
if cents < 0 || (!allowZero && cents == 0) {
return "", errors.New("充值金额不正确")
}
return fmt.Sprintf("%d.%02d", cents/100, cents%100), nil
}
func validWalletIdentity(value string) bool {
switch value {
case "host", "agency", "bd":
return true
default:
return false
}
}
func financeSalaryAssetType(identity string) (string, error) {
switch strings.TrimSpace(identity) {
case "host":
return financeAssetHostSalaryUSD, nil
case "agency":
return financeAssetAgencySalaryUSD, nil
case "bd":
return financeAssetBDSalaryUSD, nil
default:
return "", errors.New("钱包身份不正确")
}
}
func financeStockType(operation string) string {
if operation == "coin_seller_coin_debit" {
return financeStockTypeUSDTDeduction
}
return financeStockTypeUSDTPurchase
}
func signedAmount(amount int64, debit bool) int64 {
if debit {
return -amount
}
return amount
}
func decimalAmountMinor(value string) (int64, error) {
return decimalAmountWithScale(value, 2)
}
func decimalAmountMicro(value string) (int64, error) {
return decimalAmountWithScale(value, 6)
}
func decimalAmountWithScale(value string, scale int) (int64, error) {
text := strings.TrimSpace(value)
if text == "" {
return 0, errors.New("充值金额不正确")
}
parts := strings.Split(text, ".")
if len(parts) > 2 {
return 0, errors.New("充值金额不正确")
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || whole < 0 {
return 0, errors.New("充值金额不正确")
}
fracText := ""
if len(parts) == 2 {
fracText = parts[1]
}
if len(fracText) > scale {
return 0, errors.New("充值金额不正确")
}
for _, r := range fracText {
if r < '0' || r > '9' {
return 0, errors.New("充值金额不正确")
}
}
for len(fracText) < scale {
fracText += "0"
}
frac := int64(0)
if fracText != "" {
frac, err = strconv.ParseInt(fracText, 10, 64)
if err != nil {
return 0, errors.New("充值金额不正确")
}
}
multiplier := int64(1)
for i := 0; i < scale; i++ {
multiplier *= 10
}
const maxInt64 = 1<<63 - 1
if whole > (maxInt64-frac)/multiplier {
return 0, errors.New("充值金额不正确")
}
amount := whole*multiplier + frac
if amount <= 0 {
return 0, errors.New("充值金额不正确")
}
return amount, nil
}
func parsePositiveInt64(value string) (int64, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil && parsed > 0
}
func financeWalletCommandID(id uint, operation string) string {
return fmt.Sprintf("finance-application:%d:%s", id, strings.TrimSpace(operation))
}
func financeWalletEvidenceRef(id uint) string {
return fmt.Sprintf("finance-application:%d", id)
}
func financeWalletReason(application model.FinanceApplication) string {
return fmt.Sprintf("finance application %d %s", application.ID, operationLabel(application.Operation))
}
func isGRPCNotFound(err error) bool {
return status.Code(err) == codes.NotFound
}
func hasPermission(permissions []string, code string) bool {
for _, permission := range permissions {
if permission == code {
return true
}
}
return false
}

View File

@ -1,269 +0,0 @@
package financeapplication
import (
"context"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
walletv1 "hyapp.local/api/proto/wallet/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestNormalizeCreateInputRequiresOperationPermission(t *testing.T) {
service := NewService(nil)
_, err := service.normalizeCreateInput(shared.Actor{
UserID: 7,
Username: "ops",
Permissions: []string{permissionCreateApplication},
}, validCreateRequest())
if err == nil || err.Error() != "没有操作权限" {
t.Fatalf("operation permission must be required, got err=%v", err)
}
}
func TestNormalizeCreateInputRequiresWalletIdentityForWalletOperation(t *testing.T) {
service := NewService(nil)
req := validCreateRequest()
req.Operation = "user_wallet_debit"
_, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req)
if err == nil || err.Error() != "钱包身份不正确" {
t.Fatalf("wallet operation must require wallet identity, got err=%v", err)
}
req.WalletIdentity = "agency"
item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-wallet-debit"), req)
if err != nil {
t.Fatalf("wallet identity should be accepted: %v", err)
}
if item.WalletIdentity != "agency" {
t.Fatalf("wallet identity mismatch: %q", item.WalletIdentity)
}
}
func TestNormalizeCreateInputClearsWalletIdentityForCoinOperation(t *testing.T) {
service := NewService(nil)
req := validCreateRequest()
req.WalletIdentity = "host"
item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:coin-seller-coin-credit"), req)
if err != nil {
t.Fatalf("coin operation should be accepted: %v", err)
}
if item.WalletIdentity != "" {
t.Fatalf("non-wallet operation must clear wallet identity, got %q", item.WalletIdentity)
}
if item.RechargeAmount != "10.50" {
t.Fatalf("recharge amount mismatch: %q", item.RechargeAmount)
}
}
func TestNormalizeCreateInputAllowsZeroRechargeAmountForUserCoinOperation(t *testing.T) {
service := NewService(nil)
req := validCreateRequest()
req.Operation = "user_coin_credit"
req.RechargeAmount = 0
item, err := service.normalizeCreateInput(actorWithPermissions("finance-operation:user-coin-credit"), req)
if err != nil {
t.Fatalf("user coin operation should allow zero recharge amount: %v", err)
}
if item.RechargeAmount != "0.00" {
t.Fatalf("zero recharge amount must be persisted with decimal scale, got %q", item.RechargeAmount)
}
}
func TestNormalizeCreateInputRejectsZeroRechargeAmountForWalletAndCoinSellerOperations(t *testing.T) {
service := NewService(nil)
tests := []struct {
name string
operation string
permission string
identity string
}{
{name: "wallet", operation: "user_wallet_credit", permission: "finance-operation:user-wallet-credit", identity: "agency"},
{name: "coin seller", operation: "coin_seller_coin_credit", permission: "finance-operation:coin-seller-coin-credit"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := validCreateRequest()
req.Operation = tt.operation
req.WalletIdentity = tt.identity
req.RechargeAmount = 0
_, err := service.normalizeCreateInput(actorWithPermissions(tt.permission), req)
if err == nil || err.Error() != "充值金额不正确" {
t.Fatalf("operation %s must still require positive recharge amount, got err=%v", tt.operation, err)
}
})
}
}
func TestExecuteApprovedApplicationAdjustsUserCoinDebit(t *testing.T) {
wallet := &fakeFinanceWallet{}
service := NewService(nil, WithUserClient(&fakeFinanceUser{
displays: map[string]int64{"163001": 312899006709637120},
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7},
},
}), WithWalletClient(wallet))
application := model.FinanceApplication{
ID: 9,
AppCode: "lalu",
Operation: "user_coin_debit",
TargetUserID: "163001",
CoinAmount: 120,
RechargeAmount: "10.00",
}
execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 7}, application, "req-1")
if err != nil {
t.Fatalf("execute approved application failed: %v", err)
}
if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetCoin || wallet.assetReq.GetAmount() != -120 || wallet.assetReq.GetTargetUserId() != 312899006709637120 {
t.Fatalf("wallet asset request mismatch: %+v", wallet.assetReq)
}
if execution.CommandID != "finance-application:9:user_coin_debit" || execution.AmountDelta != -120 || execution.BalanceAfter != 880 {
t.Fatalf("execution mismatch: %+v", execution)
}
}
func TestExecuteApprovedApplicationUsesWalletIdentityAmountMinor(t *testing.T) {
wallet := &fakeFinanceWallet{}
service := NewService(nil, WithUserClient(&fakeFinanceUser{
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7},
},
summaries: map[int64]*userclient.UserRoleSummary{
312899006709637120: {UserID: 312899006709637120, IsAgency: true},
},
}), WithWalletClient(wallet))
application := model.FinanceApplication{
ID: 10,
AppCode: "lalu",
Operation: "user_wallet_credit",
WalletIdentity: "agency",
TargetUserID: "312899006709637120",
CoinAmount: 999,
RechargeAmount: "12.34",
}
_, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 8}, application, "req-2")
if err != nil {
t.Fatalf("execute wallet application failed: %v", err)
}
if wallet.assetReq == nil || wallet.assetReq.GetAssetType() != financeAssetAgencySalaryUSD || wallet.assetReq.GetAmount() != 1234 {
t.Fatalf("identity wallet request must use salary minor amount, got %+v", wallet.assetReq)
}
}
func TestExecuteApprovedApplicationAdjustsCoinSellerStockDeduction(t *testing.T) {
wallet := &fakeFinanceWallet{}
service := NewService(nil, WithUserClient(&fakeFinanceUser{
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, CountryID: 63, RegionID: 7},
},
summaries: map[int64]*userclient.UserRoleSummary{
312899006709637120: {UserID: 312899006709637120, IsCoinSeller: true, CoinSellerStatus: "active"},
},
}), WithWalletClient(wallet))
application := model.FinanceApplication{
ID: 11,
AppCode: "lalu",
Operation: "coin_seller_coin_debit",
TargetUserID: "312899006709637120",
CoinAmount: 3000000,
RechargeAmount: "37.50",
}
execution, err := service.executeApprovedApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{UserID: 9}, application, "req-3")
if err != nil {
t.Fatalf("execute coin seller application failed: %v", err)
}
if wallet.stockReq == nil || wallet.stockReq.GetStockType() != financeStockTypeUSDTDeduction || wallet.stockReq.GetCoinAmount() != 3000000 || wallet.stockReq.GetPaidAmountMicro() != 37500000 {
t.Fatalf("coin seller stock request mismatch: %+v", wallet.stockReq)
}
if wallet.stockReq.GetSellerCountryId() != 63 || wallet.stockReq.GetSellerRegionId() != 7 {
t.Fatalf("coin seller region snapshot mismatch: %+v", wallet.stockReq)
}
if execution.AssetType != financeAssetCoinSellerCoin || execution.AmountDelta != -3000000 || execution.BalanceAfter != 7000000 {
t.Fatalf("coin seller execution mismatch: %+v", execution)
}
}
func validCreateRequest() createApplicationRequest {
return createApplicationRequest{
AppCode: "lalu",
Operation: "coin_seller_coin_credit",
TargetUserID: "10001",
CoinAmount: 100,
RechargeAmount: 10.5,
CredentialImageURL: "https://example.com/receipt.png",
}
}
type fakeFinanceUser struct {
userclient.Client
users map[int64]*userclient.User
displays map[string]int64
summaries map[int64]*userclient.UserRoleSummary
}
func (f *fakeFinanceUser) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) {
if user := f.users[req.UserID]; user != nil {
return user, nil
}
return nil, status.Error(codes.NotFound, "user not found")
}
func (f *fakeFinanceUser) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) {
if userID := f.displays[req.DisplayUserID]; userID > 0 {
return &userclient.UserIdentity{UserID: userID, DisplayUserID: req.DisplayUserID}, nil
}
return nil, status.Error(codes.NotFound, "display user id not found")
}
func (f *fakeFinanceUser) GetUserRoleSummary(_ context.Context, req userclient.GetUserRoleSummaryRequest) (*userclient.UserRoleSummary, error) {
if summary := f.summaries[req.UserID]; summary != nil {
return summary, nil
}
return &userclient.UserRoleSummary{UserID: req.UserID}, nil
}
type fakeFinanceWallet struct {
walletclient.Client
assetReq *walletv1.AdminCreditAssetRequest
stockReq *walletv1.AdminCreditCoinSellerStockRequest
}
func (f *fakeFinanceWallet) AdminCreditAsset(_ context.Context, req *walletv1.AdminCreditAssetRequest) (*walletv1.AdminCreditAssetResponse, error) {
f.assetReq = req
return &walletv1.AdminCreditAssetResponse{
TransactionId: "tx-" + req.GetCommandId(),
Balance: &walletv1.AssetBalance{AvailableAmount: 880},
}, nil
}
func (f *fakeFinanceWallet) AdminCreditCoinSellerStock(_ context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
f.stockReq = req
coinAmount := req.GetCoinAmount()
if req.GetStockType() == financeStockTypeUSDTDeduction {
coinAmount = -coinAmount
}
return &walletv1.AdminCreditCoinSellerStockResponse{
TransactionId: "tx-" + req.GetCommandId(),
CoinAmount: coinAmount,
BalanceAfter: 7000000,
}, nil
}
func actorWithPermissions(operationPermission string) shared.Actor {
return shared.Actor{
UserID: 7,
Username: "ops",
Permissions: []string{permissionCreateApplication, operationPermission},
}
}

View File

@ -22,6 +22,7 @@ type coinSellerTarget struct {
UserID int64 UserID int64
DisplayUserID string DisplayUserID string
CountryID int64 CountryID int64
CountryCode string
RegionID int64 RegionID int64
} }
@ -125,18 +126,19 @@ func (w *mysqlLegacyCoinSellerRechargeWriter) resolveLegacyUser(ctx context.Cont
} }
matchSQL += ")" matchSQL += ")"
row := w.appDB.QueryRowContext(queryCtx, ` row := w.appDB.QueryRowContext(queryCtx, `
SELECT id, account, COALESCE(country_id, 0) SELECT id, account, COALESCE(country_id, 0), COALESCE(country_code, '')
FROM user_base_info FROM user_base_info
WHERE origin_sys = ? AND `+matchSQL+` AND COALESCE(is_del, 0) = 0 WHERE origin_sys = ? AND `+matchSQL+` AND COALESCE(is_del, 0) = 0
ORDER BY id DESC ORDER BY id DESC
LIMIT 1`, args...) LIMIT 1`, args...)
var target coinSellerTarget var target coinSellerTarget
if err := row.Scan(&target.UserID, &target.DisplayUserID, &target.CountryID); err != nil { if err := row.Scan(&target.UserID, &target.DisplayUserID, &target.CountryID, &target.CountryCode); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return coinSellerTarget{}, errLegacyCoinSellerNotFound return coinSellerTarget{}, errLegacyCoinSellerNotFound
} }
return coinSellerTarget{}, err return coinSellerTarget{}, err
} }
target.CountryCode = strings.ToUpper(strings.TrimSpace(target.CountryCode))
if strings.TrimSpace(target.DisplayUserID) == "" { if strings.TrimSpace(target.DisplayUserID) == "" {
target.DisplayUserID = formatInt64(target.UserID) target.DisplayUserID = formatInt64(target.UserID)
} }

View File

@ -100,7 +100,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(
}, appDB, walletDB) }, appDB, walletDB)
expectLegacyUserLookup(appMock, "ATYOU", "agent001"). expectLegacyUserLookup(appMock, "ATYOU", "agent001").
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id"}).AddRow(int64(10001), "agent001", int64(63))) WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id", "country_code"}).AddRow(int64(10001), "agent001", int64(63), "ph"))
expectLegacyCoinSellerCheck(walletMock, "ATYOU", 10001). expectLegacyCoinSellerCheck(walletMock, "ATYOU", 10001).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(77))) WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(77)))
@ -108,7 +108,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(
if err != nil { if err != nil {
t.Fatalf("resolve legacy coin seller failed: %v", err) t.Fatalf("resolve legacy coin seller failed: %v", err)
} }
if target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 { if target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 || target.CountryCode != "PH" {
t.Fatalf("legacy coin seller target mismatch: %+v", target) t.Fatalf("legacy coin seller target mismatch: %+v", target)
} }
if err := appMock.ExpectationsWereMet(); err != nil { if err := appMock.ExpectationsWereMet(); err != nil {
@ -131,7 +131,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerRejectsNonDealer(t *test
}, appDB, walletDB) }, appDB, walletDB)
expectLegacyUserLookup(appMock, "LIKEI", "visitor001"). expectLegacyUserLookup(appMock, "LIKEI", "visitor001").
WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id"}).AddRow(int64(20002), "visitor001", int64(86))) WillReturnRows(sqlmock.NewRows([]string{"id", "account", "country_id", "country_code"}).AddRow(int64(20002), "visitor001", int64(86), "ID"))
expectLegacyCoinSellerCheck(walletMock, "LIKEI", 20002). expectLegacyCoinSellerCheck(walletMock, "LIKEI", 20002).
WillReturnError(sql.ErrNoRows) WillReturnError(sql.ErrNoRows)
@ -153,7 +153,7 @@ func expectLegacyBalanceLock(mock sqlmock.Sqlmock, sysOrigin string, userID int6
} }
func expectLegacyUserLookup(mock sqlmock.Sqlmock, sysOrigin string, keyword string) *sqlmock.ExpectedQuery { func expectLegacyUserLookup(mock sqlmock.Sqlmock, sysOrigin string, keyword string) *sqlmock.ExpectedQuery {
return mock.ExpectQuery(`(?s)SELECT id, account, COALESCE\(country_id, 0\)\s+FROM user_base_info\s+WHERE origin_sys = \? AND \(account = \? OR CAST\(id AS CHAR\) = \?\).*LIMIT 1`). return mock.ExpectQuery(`(?s)SELECT id, account, COALESCE\(country_id, 0\), COALESCE\(country_code, ''\)\s+FROM user_base_info\s+WHERE origin_sys = \? AND \(account = \? OR CAST\(id AS CHAR\) = \?\).*LIMIT 1`).
WithArgs(sysOrigin, keyword, keyword) WithArgs(sysOrigin, keyword, keyword)
} }

View File

@ -43,16 +43,29 @@ var (
) )
type Service struct { type Service struct {
store *repository.Store store *repository.Store
wallet walletclient.Client wallet walletclient.Client
user userclient.Client user userclient.Client
now func() time.Time now func() time.Time
statsRecorder financeCoinSellerRechargeStatsRecorder statsRecorder financeCoinSellerRechargeStatsRecorder
legacyWriters map[string]LegacyCoinSellerRechargeWriter legacyWriters map[string]LegacyCoinSellerRechargeWriter
legacyRegionResolve LegacyCoinSellerRegionResolver
} }
type Option func(*Service) type Option func(*Service)
// LegacyCoinSellerRegionResolver 把 legacy 用户的国家码解析成财务/BI 通用的区域 ID
// (与 admin_user_money_scopes、账单区域分布同一套合成口径
// 没有它 Aslan/Yumi 的币商充值订单只有国家快照target_region_id 恒为 0
// 区域分布和分区域授权账号都无法归因这笔充值。
type LegacyCoinSellerRegionResolver func(ctx context.Context, appCode string, countryCode string) (int64, bool, error)
func WithLegacyCoinSellerRegionResolver(resolver LegacyCoinSellerRegionResolver) Option {
return func(service *Service) {
service.legacyRegionResolve = resolver
}
}
type financeCoinSellerRechargeStatsRecorder interface { type financeCoinSellerRechargeStatsRecorder interface {
RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error RecordFinanceCoinSellerRechargeOrder(ctx context.Context, order model.CoinSellerRechargeOrder) error
} }
@ -555,7 +568,11 @@ func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceip
func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string, appCode string, rawTarget string) (coinSellerTarget, error) { func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string, appCode string, rawTarget string) (coinSellerTarget, error) {
if writer := s.legacyWriters[appCode]; writer != nil { if writer := s.legacyWriters[appCode]; writer != nil {
return writer.ResolveCoinSeller(ctx, rawTarget) target, err := writer.ResolveCoinSeller(ctx, rawTarget)
if err != nil {
return coinSellerTarget{}, err
}
return s.attachLegacyRegion(ctx, appCode, target)
} }
if requiresLegacyWriter(appCode) { if requiresLegacyWriter(appCode) {
return coinSellerTarget{}, errors.New("legacy coin seller recharge writer is not configured") return coinSellerTarget{}, errors.New("legacy coin seller recharge writer is not configured")
@ -590,6 +607,26 @@ func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string,
}, nil }, nil
} }
// attachLegacyRegion 用国家码补齐 legacy 币商的区域归属。区域解析失败时创建必须失败:
// target_region_id=0 的订单会从所有区域分布和分区域授权账号的视图里消失,宁可让财务先补区域配置。
func (s *Service) attachLegacyRegion(ctx context.Context, appCode string, target coinSellerTarget) (coinSellerTarget, error) {
if target.CountryCode == "" {
return coinSellerTarget{}, errors.New("目标币商未配置国家,无法归因充值区域")
}
if s.legacyRegionResolve == nil {
return coinSellerTarget{}, errors.New("legacy coin seller region resolver is not configured")
}
regionID, found, err := s.legacyRegionResolve(ctx, appCode, target.CountryCode)
if err != nil {
return coinSellerTarget{}, err
}
if !found || regionID <= 0 {
return coinSellerTarget{}, fmt.Errorf("目标币商所在国家(%s)未划入任何区域,请先在区域配置中补齐", target.CountryCode)
}
target.RegionID = regionID
return target, nil
}
func (s *Service) resolveWalletUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) { func (s *Service) resolveWalletUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
keyword := strings.TrimSpace(rawTarget) keyword := strings.TrimSpace(rawTarget)
if keyword == "" { if keyword == "" {
@ -850,6 +887,8 @@ func normalizeRechargeProvider(providerCode string, chain string) (string, strin
return "usdt", "TRON", nil return "usdt", "TRON", nil
case "usdt_bep20", "bep20", "bsc", "bnb": case "usdt_bep20", "bep20", "bsc", "bnb":
return "usdt", "BSC", nil return "usdt", "BSC", nil
case "usdt_c2c", "c2c":
return "usdt", "C2C", nil
default: default:
return "", "", errors.New("充值方式不正确") return "", "", errors.New("充值方式不正确")
} }
@ -865,6 +904,8 @@ func normalizeRechargeChain(chain string) string {
return "TRON" return "TRON"
case "bsc", "bnb", "bep20", "usdt_bep20": case "bsc", "bnb", "bep20", "usdt_bep20":
return "BSC" return "BSC"
case "c2c", "usdt_c2c":
return "C2C"
default: default:
return "" return ""
} }
@ -879,6 +920,8 @@ func receiptProviderCode(order model.CoinSellerRechargeOrder) string {
return "usdt_trc20" return "usdt_trc20"
case "BSC": case "BSC":
return "usdt_bep20" return "usdt_bep20"
case "C2C":
return "usdt_c2c"
default: default:
return "usdt" return "usdt"
} }
@ -903,6 +946,8 @@ func receiptChain(order model.CoinSellerRechargeOrder) string {
return "trc20" return "trc20"
case "BSC": case "BSC":
return "bep20" return "bep20"
case "C2C":
return "c2c"
default: default:
return "" return ""
} }

View File

@ -281,9 +281,16 @@ func TestResolveCoinSellerTargetUsesLegacyWriterForAslan(t *testing.T) {
UserID: 10001, UserID: 10001,
DisplayUserID: "agent001", DisplayUserID: "agent001",
CountryID: 63, CountryID: 63,
CountryCode: "PH",
}, },
} }
service := NewService(nil, nil, nil, WithLegacyCoinSellerRechargeWriters(writer)) var resolvedApp, resolvedCountry string
service := NewService(nil, nil, nil,
WithLegacyCoinSellerRechargeWriters(writer),
WithLegacyCoinSellerRegionResolver(func(_ context.Context, appCode string, countryCode string) (int64, bool, error) {
resolvedApp, resolvedCountry = appCode, countryCode
return 2049039964177891329, true, nil
}))
target, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001") target, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001")
if err != nil { if err != nil {
t.Fatalf("resolve aslan legacy target failed: %v", err) t.Fatalf("resolve aslan legacy target failed: %v", err)
@ -291,6 +298,44 @@ func TestResolveCoinSellerTargetUsesLegacyWriterForAslan(t *testing.T) {
if writer.resolveKeyword != "agent001" || target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 { if writer.resolveKeyword != "agent001" || target.UserID != 10001 || target.DisplayUserID != "agent001" || target.CountryID != 63 {
t.Fatalf("legacy resolve mismatch: keyword=%q target=%+v", writer.resolveKeyword, target) t.Fatalf("legacy resolve mismatch: keyword=%q target=%+v", writer.resolveKeyword, target)
} }
if resolvedApp != "aslan" || resolvedCountry != "PH" || target.RegionID != 2049039964177891329 {
t.Fatalf("legacy region attribution mismatch: app=%q country=%q target=%+v", resolvedApp, resolvedCountry, target)
}
}
func TestResolveCoinSellerTargetLegacyRequiresRegionAttribution(t *testing.T) {
writer := &fakeLegacyCoinSellerRechargeWriter{
appCode: "aslan",
resolveResult: coinSellerTarget{
UserID: 10001,
DisplayUserID: "agent001",
CountryID: 63,
CountryCode: "PH",
},
}
// 国家未划入任何区域:创建必须失败,否则订单以 target_region_id=0 落库后从区域视图消失。
service := NewService(nil, nil, nil,
WithLegacyCoinSellerRechargeWriters(writer),
WithLegacyCoinSellerRegionResolver(func(_ context.Context, _ string, _ string) (int64, bool, error) {
return 0, false, nil
}))
if _, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001"); err == nil || !strings.Contains(err.Error(), "未划入任何区域") {
t.Fatalf("expected unmapped country error, got %v", err)
}
// 用户没有国家码:同样必须失败。
writer.resolveResult.CountryCode = ""
if _, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001"); err == nil || !strings.Contains(err.Error(), "未配置国家") {
t.Fatalf("expected missing country error, got %v", err)
}
// 未接入区域解析器:显式报配置错误而不是静默回到 region 0。
writer.resolveResult.CountryCode = "PH"
service = NewService(nil, nil, nil, WithLegacyCoinSellerRechargeWriters(writer))
if _, err := service.resolveCoinSellerTarget(context.Background(), "req-1", "aslan", "agent001"); err == nil || !strings.Contains(err.Error(), "region resolver is not configured") {
t.Fatalf("expected resolver configuration error, got %v", err)
}
} }
func TestVerifyReceiptMatchesOrderAmounts(t *testing.T) { func TestVerifyReceiptMatchesOrderAmounts(t *testing.T) {

View File

@ -0,0 +1,12 @@
package financewithdrawal
import "testing"
func TestCanAuditWithdrawalUsesDedicatedPermission(t *testing.T) {
if !canAuditWithdrawal([]string{"finance-withdrawal:audit"}) {
t.Fatal("dedicated withdrawal audit permission must allow auditing")
}
if canAuditWithdrawal([]string{"finance-application:audit"}) {
t.Fatal("removed finance application permission must not authorize withdrawal auditing")
}
}

View File

@ -10,7 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil { if h == nil {
return return
} }
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ListApplications) protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications)
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.ApproveApplication) protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication)
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication) protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication)
} }

View File

@ -22,7 +22,6 @@ import (
const ( const (
permissionViewWithdrawalApplications = "finance-withdrawal:view" permissionViewWithdrawalApplications = "finance-withdrawal:view"
permissionAuditWithdrawalApplications = "finance-withdrawal:audit" permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
permissionAuditFinanceApplications = "finance-application:audit"
pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultPointsPerUSD = int64(100000)
pointWithdrawalDefaultFeeBPS = int32(500) pointWithdrawalDefaultFeeBPS = int32(500)
@ -308,7 +307,7 @@ func withdrawalAuditNotificationEventID(id uint, decision string) string {
func canAuditWithdrawal(permissions []string) bool { func canAuditWithdrawal(permissions []string) bool {
for _, permission := range permissions { for _, permission := range permissions {
switch strings.TrimSpace(permission) { switch strings.TrimSpace(permission) {
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications: case permissionAuditWithdrawalApplications:
return true return true
} }
} }

View File

@ -46,6 +46,8 @@ type catalogDTO struct {
Tags []string `json:"tags"` Tags []string `json:"tags"`
CreatedAtMS int64 `json:"createdAtMs"` CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"`
// whitelistEnabled 只表示访问策略是否生效;成员通过独立接口按需加载,避免目录列表膨胀。
WhitelistEnabled bool `json:"whitelistEnabled"`
} }
type diceConfigDTO struct { type diceConfigDTO struct {
@ -215,23 +217,24 @@ func catalogFromProto(item *gamev1.GameCatalogItem) catalogDTO {
return catalogDTO{} return catalogDTO{}
} }
return catalogDTO{ return catalogDTO{
AppCode: item.GetAppCode(), AppCode: item.GetAppCode(),
GameID: item.GetGameId(), GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(), PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(), ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(), GameName: item.GetGameName(),
Category: item.GetCategory(), Category: item.GetCategory(),
IconURL: item.GetIconUrl(), IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(), CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(), LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(), Orientation: item.GetOrientation(),
SafeHeight: item.GetSafeHeight(), SafeHeight: item.GetSafeHeight(),
MinCoin: item.GetMinCoin(), MinCoin: item.GetMinCoin(),
Status: item.GetStatus(), Status: item.GetStatus(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
Tags: item.GetTags(), Tags: item.GetTags(),
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(),
WhitelistEnabled: item.GetWhitelistEnabled(),
} }
} }

View File

@ -0,0 +1,221 @@
package gamemanagement
import (
"context"
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const gameWhitelistInternalUserIDMinDigits = 15
var errGameWhitelistUserNotFound = errors.New("game whitelist user not found")
type gameWhitelistEnabledRequest struct {
Enabled bool `json:"enabled"`
}
type gameWhitelistUserRequest struct {
// 保留 userId JSON 字段兼容已发布管理端;值允许内部 user_id、默认短号或当前有效靓号。
UserID string `json:"userId"`
}
// gameWhitelistUserResolver 把白名单解析限制在 user-service owner API避免 admin-server 直查用户库复制身份口径。
type gameWhitelistUserResolver interface {
GetUser(context.Context, userclient.GetUserRequest) (*userclient.User, error)
ResolveDisplayUserID(context.Context, userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error)
}
type gameWhitelistUserDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
CreatedByAdminID int64 `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
}
// SetGameWhitelistEnabled 只切换访问策略,不隐式增删成员;空白名单开启后应明确表现为无人可见。
func (h *Handler) SetGameWhitelistEnabled(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
var req gameWhitelistEnabledRequest
if gameID == "" || c.ShouldBindJSON(&req) != nil {
response.BadRequest(c, "游戏白名单参数不正确")
return
}
resp, err := h.game.SetGameWhitelistEnabled(c.Request.Context(), &gamev1.SetGameWhitelistEnabledRequest{
Meta: requestMeta(c), GameId: gameID, Enabled: req.Enabled,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "set-game-whitelist-enabled", "game_catalog", gameID, strconv.FormatBool(req.Enabled))
response.OK(c, catalogFromProto(resp.GetGame()))
}
// ListGameWhitelistUsers 从 game-service 读取成员事实,再批量补 user-service 展示资料,避免复制用户主数据到游戏库。
func (h *Handler) ListGameWhitelistUsers(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
resp, err := h.game.ListGameWhitelistUsers(c.Request.Context(), &gamev1.ListGameWhitelistUsersRequest{
Meta: requestMeta(c), GameId: gameID, PageSize: int32(parsePositiveInt(c.Query("pageSize"), 200)),
})
if err != nil {
response.ServerError(c, "获取游戏白名单失败")
return
}
userIDs := make([]int64, 0, len(resp.GetUsers()))
for _, item := range resp.GetUsers() {
userIDs = append(userIDs, item.GetUserId())
}
profiles := map[int64]*userclient.User{}
if len(userIDs) > 0 {
profiles, err = h.user.BatchGetUsers(c.Request.Context(), userclient.BatchGetUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", UserIDs: userIDs,
})
if err != nil {
response.ServerError(c, "获取白名单用户资料失败")
return
}
}
items := make([]gameWhitelistUserDTO, 0, len(resp.GetUsers()))
for _, item := range resp.GetUsers() {
items = append(items, gameWhitelistUserFromProto(item, profiles[item.GetUserId()]))
}
response.OK(c, gin.H{"items": items, "serverTimeMs": resp.GetServerTimeMs()})
}
// AddGameWhitelistUser 先把内部 ID、默认短号或当前有效靓号统一解析为 owner user_id再写游戏访问成员表。
func (h *Handler) AddGameWhitelistUser(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
var req gameWhitelistUserRequest
if gameID == "" || c.ShouldBindJSON(&req) != nil {
response.BadRequest(c, "游戏白名单用户参数不正确")
return
}
keyword := strings.TrimSpace(req.UserID)
if keyword == "" {
response.BadRequest(c, "请输入用户 ID、短号或靓号")
return
}
profile, targetKind, err := resolveGameWhitelistUser(c.Request.Context(), h.user, middleware.CurrentRequestID(c), keyword)
if errors.Is(err, errGameWhitelistUserNotFound) {
response.BadRequest(c, "未找到匹配的用户")
return
}
if err != nil || profile == nil {
response.ServerError(c, "匹配用户失败")
return
}
resp, err := h.game.AddGameWhitelistUser(c.Request.Context(), &gamev1.AddGameWhitelistUserRequest{
Meta: requestMeta(c), GameId: gameID, UserId: profile.UserID,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
// 审计只记录最终内部 user_id 和解析类型,避免后续短号/靓号变更让历史记录失去指向。
h.auditLog(c, "add-game-whitelist-user", "game_user_whitelist", gameID+":"+strconv.FormatInt(profile.UserID, 10), "added target_kind="+targetKind)
response.OK(c, gameWhitelistUserFromProto(resp.GetUser(), profile))
}
// resolveGameWhitelistUser 延续后台现有身份优先级:短数字和字母靓号优先按展示号解析,雪花长整型优先按内部 ID 读取。
func resolveGameWhitelistUser(ctx context.Context, resolver gameWhitelistUserResolver, requestID string, keyword string) (*userclient.User, string, error) {
keyword = strings.TrimSpace(keyword)
if resolver == nil || keyword == "" {
return nil, "", errGameWhitelistUserNotFound
}
numericID, numericErr := strconv.ParseInt(keyword, 10, 64)
numericOK := numericErr == nil && numericID > 0
userIDChecked := false
// 生产内部 user_id 是雪花长整型;先查长数字可保持已发布内部 ID 输入语义,同时仍允许长数字靓号在未命中后回退解析。
if numericOK && len(strings.TrimLeft(keyword, "0")) >= gameWhitelistInternalUserIDMinDigits {
userIDChecked = true
if profile, found, err := getGameWhitelistUser(ctx, resolver, requestID, numericID); err != nil {
return nil, "", err
} else if found {
return profile, "user_id", nil
}
}
identity, err := resolver.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
RequestID: requestID, Caller: "admin-server", DisplayUserID: keyword,
})
if err == nil && identity != nil && identity.UserID > 0 {
profile, found, getErr := getGameWhitelistUser(ctx, resolver, requestID, identity.UserID)
if getErr != nil {
return nil, "", getErr
}
if found {
return profile, "display_user_id", nil
}
} else if err != nil && status.Code(err) != codes.NotFound && status.Code(err) != codes.InvalidArgument {
// user-service 不可用等系统错误不能伪装成用户不存在,否则运营重试时无法判断真实故障。
return nil, "", err
}
if numericOK && !userIDChecked {
if profile, found, err := getGameWhitelistUser(ctx, resolver, requestID, numericID); err != nil {
return nil, "", err
} else if found {
return profile, "user_id", nil
}
}
return nil, "", errGameWhitelistUserNotFound
}
func getGameWhitelistUser(ctx context.Context, resolver gameWhitelistUserResolver, requestID string, userID int64) (*userclient.User, bool, error) {
profile, err := resolver.GetUser(ctx, userclient.GetUserRequest{RequestID: requestID, Caller: "admin-server", UserID: userID})
if status.Code(err) == codes.NotFound || profile == nil && err == nil {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return profile, true, nil
}
func (h *Handler) DeleteGameWhitelistUser(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
if gameID == "" || err != nil || userID <= 0 {
response.BadRequest(c, "游戏白名单用户参数不正确")
return
}
if _, err := h.game.DeleteGameWhitelistUser(c.Request.Context(), &gamev1.DeleteGameWhitelistUserRequest{
Meta: requestMeta(c), GameId: gameID, UserId: userID,
}); err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "delete-game-whitelist-user", "game_user_whitelist", gameID+":"+strconv.FormatInt(userID, 10), "deleted")
response.OK(c, gin.H{"userId": strconv.FormatInt(userID, 10)})
}
func gameWhitelistUserFromProto(item *gamev1.GameWhitelistUser, profile *userclient.User) gameWhitelistUserDTO {
if item == nil {
return gameWhitelistUserDTO{}
}
dto := gameWhitelistUserDTO{
UserID: strconv.FormatInt(item.GetUserId(), 10), CreatedByAdminID: item.GetCreatedByAdminId(), CreatedAtMS: item.GetCreatedAtMs(),
}
if profile != nil {
dto.DisplayUserID = profile.DisplayUserID
dto.Username = profile.Username
dto.Avatar = profile.Avatar
dto.Status = profile.Status
}
return dto
}

View File

@ -0,0 +1,92 @@
package gamemanagement
import (
"context"
"errors"
"reflect"
"strconv"
"testing"
"hyapp-admin-server/internal/integration/userclient"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type fakeGameWhitelistUserResolver struct {
users map[int64]*userclient.User
identities map[string]*userclient.UserIdentity
calls []string
}
func (f *fakeGameWhitelistUserResolver) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) {
f.calls = append(f.calls, "get:"+strconv.FormatInt(req.UserID, 10))
if profile := f.users[req.UserID]; profile != nil {
return profile, nil
}
return nil, status.Error(codes.NotFound, "user not found")
}
func (f *fakeGameWhitelistUserResolver) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) {
f.calls = append(f.calls, "resolve:"+req.DisplayUserID)
if identity := f.identities[req.DisplayUserID]; identity != nil {
return identity, nil
}
return nil, status.Error(codes.NotFound, "display_user_id not found")
}
func TestResolveGameWhitelistUserAcceptsShortAndPrettyDisplayIDs(t *testing.T) {
for _, keyword := range []string{"163001", "VIP2026"} {
t.Run(keyword, func(t *testing.T) {
resolver := &fakeGameWhitelistUserResolver{
users: map[int64]*userclient.User{88: {UserID: 88, DisplayUserID: keyword, Username: "matched"}},
identities: map[string]*userclient.UserIdentity{keyword: {UserID: 88, DisplayUserID: keyword}},
}
profile, kind, err := resolveGameWhitelistUser(context.Background(), resolver, "req", keyword)
if err != nil || profile == nil || profile.UserID != 88 || kind != "display_user_id" {
t.Fatalf("resolve %q = profile=%+v kind=%q err=%v", keyword, profile, kind, err)
}
if want := []string{"resolve:" + keyword, "get:88"}; !reflect.DeepEqual(resolver.calls, want) {
t.Fatalf("calls = %v, want %v", resolver.calls, want)
}
})
}
}
func TestResolveGameWhitelistUserKeepsLongInternalIDAndFallsBackForLongPrettyID(t *testing.T) {
const internalID = int64(312899006709637120)
resolver := &fakeGameWhitelistUserResolver{
users: map[int64]*userclient.User{internalID: {UserID: internalID}},
identities: map[string]*userclient.UserIdentity{},
}
profile, kind, err := resolveGameWhitelistUser(context.Background(), resolver, "req", strconv.FormatInt(internalID, 10))
if err != nil || profile.UserID != internalID || kind != "user_id" {
t.Fatalf("internal ID resolve = profile=%+v kind=%q err=%v", profile, kind, err)
}
if want := []string{"get:312899006709637120"}; !reflect.DeepEqual(resolver.calls, want) {
t.Fatalf("calls = %v, want %v", resolver.calls, want)
}
resolver = &fakeGameWhitelistUserResolver{
users: map[int64]*userclient.User{77: {UserID: 77, DisplayUserID: "312899006709637120"}},
identities: map[string]*userclient.UserIdentity{
"312899006709637120": {UserID: 77, DisplayUserID: "312899006709637120"},
},
}
profile, kind, err = resolveGameWhitelistUser(context.Background(), resolver, "req", "312899006709637120")
if err != nil || profile.UserID != 77 || kind != "display_user_id" {
t.Fatalf("long pretty ID resolve = profile=%+v kind=%q err=%v", profile, kind, err)
}
wantCalls := []string{"get:312899006709637120", "resolve:312899006709637120", "get:77"}
if !reflect.DeepEqual(resolver.calls, wantCalls) {
t.Fatalf("calls = %v, want %v", resolver.calls, wantCalls)
}
}
func TestResolveGameWhitelistUserReportsMissingTarget(t *testing.T) {
resolver := &fakeGameWhitelistUserResolver{users: map[int64]*userclient.User{}, identities: map[string]*userclient.UserIdentity{}}
_, _, err := resolveGameWhitelistUser(context.Background(), resolver, "req", "VIP404")
if !errors.Is(err, errGameWhitelistUserNotFound) {
t.Fatalf("error = %v, want errGameWhitelistUserNotFound", err)
}
}

View File

@ -11,32 +11,39 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
return return
} }
protected.GET("/admin/game/platforms", middleware.RequirePermission("game:view"), h.ListPlatforms) // 游戏列表、自研游戏、机器人和房内猜拳是四个独立授权模块legacy game:* 只用于兼容存量自定义角色。
protected.POST("/admin/game/platforms", middleware.RequirePermission("game:update"), h.CreatePlatform) protected.GET("/admin/game/platforms", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListPlatforms)
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequirePermission("game:update"), h.UpdatePlatform) protected.POST("/admin/game/platforms", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.CreatePlatform)
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequirePermission("game:update"), h.SyncPlatformGames) protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.UpdatePlatform)
protected.GET("/admin/game/games", middleware.RequirePermission("game:view"), h.ListCatalog) protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.SyncPlatformGames)
protected.POST("/admin/game/games", middleware.RequirePermission("game:create"), h.CreateCatalog) protected.GET("/admin/game/games", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListCatalog)
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog) protected.POST("/admin/game/games", middleware.RequireAnyPermission("game-catalog:create", "game:create"), h.CreateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus) protected.PATCH("/admin/game/games/:game_id", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.UpdateCatalog)
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog) protected.PATCH("/admin/game/games/:game_id/status", middleware.RequireAnyPermission("game-catalog:status", "game:status"), h.SetGameStatus)
protected.GET("/admin/game/self-games", middleware.RequirePermission("game:view"), h.ListSelfGames) protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game-catalog:delete", "game:delete", "game:update"), h.DeleteCatalog)
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequirePermission("game:update"), h.UpdateDiceConfig) protected.PATCH("/admin/game/games/:game_id/whitelist", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.SetGameWhitelistEnabled)
protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:view"), h.GetSelfGameNewUserPolicy) protected.GET("/admin/game/games/:game_id/whitelist/users", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListGameWhitelistUsers)
protected.PATCH("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:update"), h.UpdateSelfGameNewUserPolicy) protected.POST("/admin/game/games/:game_id/whitelist/users", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.AddGameWhitelistUser)
protected.GET("/admin/game/self-games/:game_id/stake-pools", middleware.RequirePermission("game:view"), h.ListSelfGameStakePools) protected.DELETE("/admin/game/games/:game_id/whitelist/users/:user_id", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.DeleteGameWhitelistUser)
protected.PATCH("/admin/game/self-games/:game_id/stake-pools/:stake_coin", middleware.RequirePermission("game:update"), h.UpdateSelfGameStakePool)
protected.POST("/admin/game/self-games/:game_id/stake-pools/:stake_coin/adjust", middleware.RequirePermission("game:update"), h.AdjustSelfGameStakePool) protected.GET("/admin/game/self-games", middleware.RequireAnyPermission("self-game:view", "game:view"), h.ListSelfGames)
protected.GET("/admin/game/robots", middleware.RequirePermission("game:view"), h.ListDiceRobots) protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateDiceConfig)
protected.POST("/admin/game/robots/generate", middleware.RequirePermission("game:create"), h.GenerateDiceRobots) protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequireAnyPermission("self-game:view", "game:view"), h.GetSelfGameNewUserPolicy)
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequirePermission("game:update"), h.SetDiceRobotStatus) protected.PATCH("/admin/game/self-games/:game_id/new-user-policy", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateSelfGameNewUserPolicy)
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteDiceRobot) protected.GET("/admin/game/self-games/:game_id/stake-pools", middleware.RequireAnyPermission("self-game:view", "game:view"), h.ListSelfGameStakePools)
// 房内猜拳后台沿用游戏管理权限但路由、DTO 和 game-service RPC 都独立于骰子和独立猜拳,避免配置互相污染。 protected.PATCH("/admin/game/self-games/:game_id/stake-pools/:stake_coin", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateSelfGameStakePool)
protected.GET("/admin/game/room-rps/config", middleware.RequirePermission("game:view"), h.GetRoomRPSConfig) protected.POST("/admin/game/self-games/:game_id/stake-pools/:stake_coin/adjust", middleware.RequireAnyPermission("self-game:update", "game:update"), h.AdjustSelfGameStakePool)
protected.PATCH("/admin/game/room-rps/config", middleware.RequirePermission("game:update"), h.UpdateRoomRPSConfig)
protected.GET("/admin/game/room-rps/challenges", middleware.RequirePermission("game:view"), h.ListRoomRPSChallenges) protected.GET("/admin/game/robots", middleware.RequireAnyPermission("game-robot:view", "game:view"), h.ListDiceRobots)
protected.GET("/admin/game/room-rps/challenges/:challenge_id", middleware.RequirePermission("game:view"), h.GetRoomRPSChallenge) protected.POST("/admin/game/robots/generate", middleware.RequireAnyPermission("game-robot:create", "game:create"), h.GenerateDiceRobots)
// 结算重试和手动过期都是会推进订单状态的运维动作,必须使用 game:update并由 game-service 再做状态机保护。 protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequireAnyPermission("game-robot:update", "game:update"), h.SetDiceRobotStatus)
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequirePermission("game:update"), h.RetryRoomRPSSettlement) protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game-robot:delete", "game:delete", "game:update"), h.DeleteDiceRobot)
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequirePermission("game:update"), h.ExpireRoomRPSChallenge)
protected.GET("/admin/game/room-rps/config", middleware.RequireAnyPermission("room-rps-config:view", "game:view"), h.GetRoomRPSConfig)
protected.PATCH("/admin/game/room-rps/config", middleware.RequireAnyPermission("room-rps-config:update", "game:update"), h.UpdateRoomRPSConfig)
protected.GET("/admin/game/room-rps/challenges", middleware.RequireAnyPermission("room-rps-order:view", "game:view"), h.ListRoomRPSChallenges)
protected.GET("/admin/game/room-rps/challenges/:challenge_id", middleware.RequireAnyPermission("room-rps-order:view", "game:view"), h.GetRoomRPSChallenge)
// 结算重试和手动过期都会推进订单状态,必须使用订单更新权限,并由 game-service 再做状态机保护。
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequireAnyPermission("room-rps-order:update", "game:update"), h.RetryRoomRPSSettlement)
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequireAnyPermission("room-rps-order:update", "game:update"), h.ExpireRoomRPSChallenge)
} }

View File

@ -29,6 +29,7 @@ const (
adapterVivaGamesV1 = "vivagames_v1" adapterVivaGamesV1 = "vivagames_v1"
adapterReyouV1 = "reyou_v1" adapterReyouV1 = "reyou_v1"
adapterZGameV1 = "zgame_v1" adapterZGameV1 = "zgame_v1"
adapterAMGV1 = "amg_v1"
adapterYomiV4 = "yomi_v4" adapterYomiV4 = "yomi_v4"
defaultGameStatus = "disabled" defaultGameStatus = "disabled"
defaultGameCategory = "casino" defaultGameCategory = "casino"
@ -141,6 +142,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
return fetchReyouGameSyncPlan(platform, req) return fetchReyouGameSyncPlan(platform, req)
case adapterZGameV1: case adapterZGameV1:
return fetchZGameGameSyncPlan(platform, req) return fetchZGameGameSyncPlan(platform, req)
case adapterAMGV1:
return fetchAMGGameSyncPlan(platform, req)
case adapterYomiV4: case adapterYomiV4:
return fetchYomiGameSyncPlan(platform, req) return fetchYomiGameSyncPlan(platform, req)
default: default:
@ -213,6 +216,14 @@ type zgameSyncConfig struct {
GameCovers map[string]string `json:"game_covers"` GameCovers map[string]string `json:"game_covers"`
} }
type amgSyncConfig struct {
// AMG 文档未提供游戏列表 APIgame_urls 的 key 必须使用 settlement 回调中的数值 gameId。
GameURLs map[string]string `json:"game_urls"`
GameNames map[string]string `json:"game_names"`
GameIcons map[string]string `json:"game_icons"`
GameCovers map[string]string `json:"game_covers"`
}
type yomiSyncConfig struct { type yomiSyncConfig struct {
// Yomi V4 文档只提供启动鉴权地址,没有游戏列表 API后台通过 game_names 维护截图里的 gameUid 清单。 // Yomi V4 文档只提供启动鉴权地址,没有游戏列表 API后台通过 game_names 维护截图里的 gameUid 清单。
GameNames map[string]string `json:"game_names"` GameNames map[string]string `json:"game_names"`
@ -392,6 +403,18 @@ func fetchZGameGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest)
}, nil }, nil
} }
func fetchAMGGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeAMGSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
return providerGameSyncPlan{}, err
}
games, gameURLs := amgCatalogItems(platform.GetPlatformCode(), config, req)
if len(games) == 0 {
return providerGameSyncPlan{}, fmt.Errorf("AMG 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射")
}
return providerGameSyncPlan{Games: games, GameURLs: gameURLs}, nil
}
func fetchYomiGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { func fetchYomiGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeYomiSyncConfig(platform.GetAdapterConfigJson()) config, err := decodeYomiSyncConfig(platform.GetAdapterConfigJson())
if err != nil { if err != nil {
@ -429,6 +452,17 @@ func decodeZGameSyncConfig(raw string) (zgameSyncConfig, error) {
return config, nil return config, nil
} }
func decodeAMGSyncConfig(raw string) (amgSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return amgSyncConfig{}, nil
}
var config amgSyncConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return amgSyncConfig{}, fmt.Errorf("AMG 适配器配置 JSON 不合法")
}
return config, nil
}
func decodeYomiSyncConfig(raw string) (yomiSyncConfig, error) { func decodeYomiSyncConfig(raw string) (yomiSyncConfig, error) {
if strings.TrimSpace(raw) == "" { if strings.TrimSpace(raw) == "" {
return yomiSyncConfig{}, nil return yomiSyncConfig{}, nil
@ -511,6 +545,42 @@ func zgameCatalogItems(platformCode string, config zgameSyncConfig, req syncGame
return items, gameURLs return items, gameURLs
} }
func amgCatalogItems(platformCode string, config amgSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameURLs))
for providerGameID, launchURL := range config.GameURLs {
// settlement.gameId 是数值;同步阶段就拒绝非数值 key避免启动成功后回调永远匹配不到 session。
if value, err := strconv.ParseInt(strings.TrimSpace(providerGameID), 10, 64); err == nil && value > 0 && strings.TrimSpace(launchURL) != "" {
keys = append(keys, strings.TrimSpace(providerGameID))
}
}
sort.Strings(keys)
items := make([]catalogRequest, 0, len(keys))
gameURLs := make(map[string]string, len(keys))
for index, providerGameID := range keys {
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
gameURLs[providerGameID] = launchURL
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
items = append(items, catalogRequest{
GameID: stableGameID(platformCode, providerGameID),
PlatformCode: strings.TrimSpace(platformCode),
ProviderGameID: providerGameID,
GameName: name,
Category: defaulted(req.Category, defaultGameCategory),
IconURL: iconURL,
CoverURL: coverURL,
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterAMGV1}, req.Tags...)),
})
}
return items, gameURLs
}
func yomiCatalogItems(platformCode string, config yomiSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) { func yomiCatalogItems(platformCode string, config yomiSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameNames)+len(config.GameURLs)) keys := make([]string, 0, len(config.GameNames)+len(config.GameURLs))
seen := make(map[string]struct{}, len(config.GameNames)+len(config.GameURLs)) seen := make(map[string]struct{}, len(config.GameNames)+len(config.GameURLs))

View File

@ -201,6 +201,36 @@ func TestFetchZGameGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
} }
} }
func TestFetchAMGGameSyncPlanReadsConfiguredNumericGameURLs(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "amg",
AdapterType: adapterAMGV1,
AdapterConfigJson: `{
"app_key":"kzphamrv01",
"game_urls":{
"1002":"https://dev.playamg.com/egyptslot/index.html",
"1001":"https://dev.playamg.com/greedy/index.html",
"not-a-game-id":"https://dev.playamg.com/ignored/index.html"
},
"game_names":{"1001":"Greedy","1002":"Egypt Slot"}
}`,
}, syncGamesRequest{})
if err != nil {
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
}
if len(plan.Games) != 2 {
t.Fatalf("games len = %d, want 2: %+v", len(plan.Games), plan.Games)
}
first := plan.Games[0]
second := plan.Games[1]
if first.GameID != "amg_1001" || first.ProviderGameID != "1001" || first.GameName != "Greedy" || second.ProviderGameID != "1002" {
t.Fatalf("AMG catalog mismatch: first=%+v second=%+v", first, second)
}
if plan.GameURLs["1001"] != "https://dev.playamg.com/greedy/index.html" || len(first.Tags) < 2 || first.Tags[1] != adapterAMGV1 {
t.Fatalf("AMG URL/tags mismatch: plan=%+v game=%+v", plan.GameURLs, first)
}
}
func TestFetchYomiGameSyncPlanReadsConfiguredGameNames(t *testing.T) { func TestFetchYomiGameSyncPlanReadsConfiguredGameNames(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{ plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "yomi", PlatformCode: "yomi",

View File

@ -0,0 +1,30 @@
package giftrecord
type userDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId,omitempty"`
DefaultDisplayUserID string `json:"defaultDisplayUserId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
PrettyID string `json:"prettyId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type giftDTO struct {
GiftID string `json:"giftId"`
Name string `json:"name"`
CoverURL string `json:"coverUrl"`
}
type recordDTO struct {
TransactionID string `json:"transactionId"`
Scene string `json:"scene"`
RoomID string `json:"roomId,omitempty"`
Sender userDTO `json:"sender"`
Receiver userDTO `json:"receiver"`
Gift giftDTO `json:"gift"`
GiftCount int32 `json:"giftCount"`
UnitValue int64 `json:"unitValue"`
GiftValue int64 `json:"giftValue"`
CreatedAtMS int64 `json:"createdAtMs"`
}

View File

@ -0,0 +1,87 @@
package giftrecord
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) List(c *gin.Context) {
query, ok := parseListQuery(c)
if !ok {
return
}
items, total, err := h.service.List(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)
scene := strings.ToLower(strings.TrimSpace(firstQuery(c, "scene")))
if scene == "all" {
scene = ""
}
if scene != "" && scene != sceneRoom && scene != sceneDirect {
response.BadRequest(c, "送礼场景不正确")
return listQuery{}, false
}
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
}
// 时间筛选统一为 [start_at_ms, end_at_ms),与账务和统计边界保持一致,避免相邻区间重复展示同一笔送礼。
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
Scene: scene,
SenderFilter: shared.UserIdentityFilterFromQuery(c, "sender"),
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)
return parsed, err == nil && parsed >= 0
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,41 @@
package giftrecord
import (
"strings"
"hyapp-admin-server/internal/modules/shared"
)
const (
sceneRoom = "room"
sceneDirect = "direct"
)
type listQuery struct {
Page int
PageSize int
Scene string
SenderFilter shared.UserIdentityFilter
StartAtMS int64
EndAtMS int64
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 30
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.SenderFilter.UserID = strings.TrimSpace(query.SenderFilter.UserID)
query.SenderFilter.DisplayUserID = strings.TrimSpace(query.SenderFilter.DisplayUserID)
query.SenderFilter.Username = strings.TrimSpace(query.SenderFilter.Username)
return query
}
func listOffset(query listQuery) int {
return (query.Page - 1) * query.PageSize
}

View File

@ -0,0 +1,14 @@
package giftrecord
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/gift-records", middleware.RequirePermission("gift-record:view"), h.List)
}

View File

@ -0,0 +1,349 @@
package giftrecord
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/modules/shared"
)
const (
bizTypeGiftDebit = "gift_debit"
bizTypeDirectGiftDebit = "direct_gift_debit"
walletStatusSucceeded = "succeeded"
)
type Service struct {
userDB *sql.DB
walletDB *sql.DB
}
type giftMetadata struct {
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftIconURL string `json:"gift_icon_url"`
GiftCount int32 `json:"gift_count"`
CoinPrice int64 `json:"coin_price"`
ChargeAmount int64 `json:"charge_amount"`
CoinSpent int64 `json:"coin_spent"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
RoomID string `json:"room_id"`
DirectGift bool `json:"direct_gift"`
}
type userProfile struct {
UserID int64
DisplayUserID string
DefaultDisplayUserID string
PrettyDisplayUserID string
PrettyID string
Username string
Avatar string
}
func NewService(userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userDB: userDB, walletDB: walletDB}
}
// List 只读取 wallet-service 已成功落账的交易快照:名称、封面、数量和价格都来自送礼发生时的 metadata
// 不回查当前礼物配置覆盖历史事实,也不扫描 room_outbox 这类会按保留策略清理的投递表。
func (s *Service) List(ctx context.Context, appCode string, query listQuery) ([]recordDTO, int64, error) {
query = normalizeListQuery(query)
if s == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
}
senderIDs, senderFiltered, err := s.resolveSenderFilter(ctx, appCode, query.SenderFilter)
if err != nil {
return nil, 0, err
}
if senderFiltered && len(senderIDs) == 0 {
// 用户身份筛选没有命中时直接返回空页,避免在钱包大表上构造永远为假的扫描条件。
return []recordDTO{}, 0, nil
}
whereSQL, args := giftRecordWhere(appCode, query, senderIDs)
var total int64
if err := s.walletDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM wallet_transactions "+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
// created_at_ms + transaction_id 组成稳定倒序;对应 owner 库组合索引,避免同一毫秒多笔交易跨页重复或遗漏。
rows, err := s.walletDB.QueryContext(ctx, `
SELECT transaction_id, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}'), created_at_ms
FROM wallet_transactions
`+whereSQL+`
ORDER BY created_at_ms DESC, transaction_id DESC
LIMIT ? OFFSET ?`, append(args, query.PageSize, listOffset(query))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]recordDTO, 0, query.PageSize)
userIDs := make([]int64, 0, query.PageSize*2)
for rows.Next() {
var item recordDTO
var bizType string
var metadataJSON string
if err := rows.Scan(&item.TransactionID, &bizType, &metadataJSON, &item.CreatedAtMS); err != nil {
return nil, 0, err
}
var metadata giftMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return nil, 0, fmt.Errorf("decode gift transaction %s metadata: %w", item.TransactionID, err)
}
item = recordFromMetadata(item, bizType, metadata)
items = append(items, item)
userIDs = append(userIDs, metadata.SenderUserID, metadata.TargetUserID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, 0, err
}
for index := range items {
senderID, _ := strconv.ParseInt(items[index].Sender.UserID, 10, 64)
receiverID, _ := strconv.ParseInt(items[index].Receiver.UserID, 10, 64)
// 用户资料只补充公共用户组件所需展示字段;即使用户资料缺失,也保留账务快照中的长 ID不吞掉送礼事实。
if profile, ok := profiles[senderID]; ok {
items[index].Sender = userDTOFromProfile(profile)
}
if profile, ok := profiles[receiverID]; ok {
items[index].Receiver = userDTOFromProfile(profile)
}
}
return items, total, nil
}
func giftRecordWhere(appCode string, query listQuery, senderIDs []int64) (string, []any) {
where := "WHERE app_code = ? AND status = ?"
args := []any{strings.TrimSpace(appCode), walletStatusSucceeded}
switch query.Scene {
case sceneRoom:
where += " AND biz_type = ?"
args = append(args, bizTypeGiftDebit)
case sceneDirect:
where += " AND biz_type = ?"
args = append(args, bizTypeDirectGiftDebit)
default:
where += " AND biz_type IN (?, ?)"
args = append(args, bizTypeGiftDebit, bizTypeDirectGiftDebit)
}
if query.StartAtMS > 0 {
where += " AND created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if len(senderIDs) > 0 {
// gift_sender_user_id 是 wallet owner 从不可变 metadata 快照生成的索引列;后台不能为筛选跨库 JOIN 用户表,
// 因此先在 user DB 解析公共身份字段,再用内部 user_id 命中钱包组合索引完成分页。
where += " AND gift_sender_user_id IN (" + placeholders(len(senderIDs)) + ")"
for _, senderID := range senderIDs {
args = append(args, senderID)
}
}
return where, args
}
func (s *Service) resolveSenderFilter(ctx context.Context, appCode string, filter shared.UserIdentityFilter) ([]int64, bool, error) {
if filter.IsEmpty() {
return nil, false, nil
}
directUserIDs := make([]int64, 0, 1)
if userID, err := strconv.ParseInt(strings.TrimSpace(filter.UserID), 10, 64); err == nil && userID > 0 {
// 钱包事实必须在用户资料已删除或未同步时仍可按内部长 ID 检索。
directUserIDs = append(directUserIDs, userID)
}
if s == nil || s.userDB == nil {
if len(directUserIDs) > 0 {
return directUserIDs, true, nil
}
return nil, true, fmt.Errorf("user mysql is not configured")
}
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", filter, time.Now().UTC().UnixMilli())
args := append([]any{strings.TrimSpace(appCode)}, matchArgs...)
// 公共用户筛选允许昵称模糊匹配;最多解析 1000 个内部 ID既覆盖正常运营检索又限制后续钱包 IN 条件规模。
rows, err := s.userDB.QueryContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY u.updated_at_ms DESC, u.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)
}
if err := rows.Err(); err != nil {
return nil, true, err
}
return uniquePositiveInt64s(userIDs), true, nil
}
func recordFromMetadata(item recordDTO, bizType string, metadata giftMetadata) recordDTO {
item.Scene = sceneRoom
if bizType == bizTypeDirectGiftDebit || metadata.DirectGift {
item.Scene = sceneDirect
}
item.RoomID = strings.TrimSpace(metadata.RoomID)
item.Sender = userDTO{UserID: formatUserID(metadata.SenderUserID)}
item.Receiver = userDTO{UserID: formatUserID(metadata.TargetUserID)}
item.Gift = giftDTO{
GiftID: strings.TrimSpace(metadata.GiftID),
Name: firstNonEmpty(metadata.GiftName, metadata.GiftID, "-"),
CoverURL: strings.TrimSpace(metadata.GiftIconURL),
}
item.GiftCount = metadata.GiftCount
item.UnitValue = metadata.CoinPrice
item.GiftValue = metadata.CoinSpent
if item.GiftValue <= 0 {
item.GiftValue = metadata.ChargeAmount
}
if item.UnitValue <= 0 && item.GiftCount > 0 {
item.UnitValue = item.GiftValue / int64(item.GiftCount)
}
return item
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
result := make(map[int64]userProfile)
if s == nil || s.userDB == nil {
return result, nil
}
userIDs = uniquePositiveInt64s(userIDs)
if len(userIDs) == 0 {
return result, nil
}
nowMS := time.Now().UTC().UnixMilli()
args := []any{nowMS, nowMS, strings.TrimSpace(appCode)}
for _, userID := range userIDs {
args = append(args, userID)
}
// 每页最多查询 2 * page_size 个用户;靓号子查询均命中 (app_code,user_id,status) 索引,
// 这样公共用户组件能展示短 ID/靓号,又不会把用户表 join 进大体量钱包分页查询。
rows, err := s.userDB.QueryContext(ctx, `
SELECT users.user_id,
users.current_display_user_id,
COALESCE(users.default_display_user_id, ''),
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE((
SELECT pretty.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pretty
ON pretty.app_code = lease.app_code
AND pretty.assigned_lease_id = lease.lease_id
AND pretty.status = 'assigned'
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE(users.username, ''), COALESCE(users.avatar, '')
FROM users
WHERE users.app_code = ? AND users.user_id IN (`+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.DefaultDisplayUserID,
&profile.PrettyDisplayUserID,
&profile.PrettyID,
&profile.Username,
&profile.Avatar,
); err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
func userDTOFromProfile(profile userProfile) userDTO {
return userDTO{
UserID: formatUserID(profile.UserID),
DisplayUserID: profile.DisplayUserID,
DefaultDisplayUserID: profile.DefaultDisplayUserID,
PrettyDisplayUserID: profile.PrettyDisplayUserID,
PrettyID: profile.PrettyID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func formatUserID(userID int64) string {
if userID <= 0 {
return ""
}
return strconv.FormatInt(userID, 10)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}

View File

@ -0,0 +1,122 @@
package giftrecord
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"hyapp-admin-server/internal/modules/shared"
)
func TestListUsesWalletGiftSnapshotAndEnrichesBothUsers(t *testing.T) {
walletDB, walletMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new wallet sqlmock: %v", err)
}
defer walletDB.Close()
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new user sqlmock: %v", err)
}
defer userDB.Close()
walletMock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM wallet_transactions WHERE app_code = ? AND status = ? AND biz_type IN (?, ?)")).
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
walletMock.ExpectQuery("SELECT transaction_id, biz_type, COALESCE\\(CAST\\(metadata_json AS CHAR\\), '\\{\\}'\\), created_at_ms").
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit, 30, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "biz_type", "metadata_json", "created_at_ms"}).AddRow(
"wallet-gift-1",
bizTypeGiftDebit,
`{"gift_id":"rose","gift_name":"玫瑰","gift_icon_url":"https://cdn.example/rose.webp","gift_count":3,"coin_price":100,"coin_spent":300,"sender_user_id":1001,"target_user_id":2002,"room_id":"room-9"}`,
int64(1770000000000),
))
userMock.ExpectQuery("SELECT users.user_id,").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(1001), int64(2002)).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar"}).
AddRow(int64(1001), "101", "101", "888888", "pretty-1", "送礼人", "sender.webp").
AddRow(int64(2002), "202", "202", "", "", "收礼人", "receiver.webp"))
items, total, err := NewService(userDB, walletDB).List(context.Background(), "fami", listQuery{Page: 1, PageSize: 30})
if err != nil {
t.Fatalf("list gift records: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("unexpected page total=%d items=%d", total, len(items))
}
item := items[0]
if item.Scene != sceneRoom || item.RoomID != "room-9" {
t.Fatalf("scene snapshot mismatch: %+v", item)
}
if item.Sender.Username != "送礼人" || item.Sender.PrettyDisplayUserID != "888888" || item.Receiver.Username != "收礼人" {
t.Fatalf("user enrichment mismatch: %+v", item)
}
if item.Gift.Name != "玫瑰" || item.Gift.CoverURL == "" || item.GiftCount != 3 || item.UnitValue != 100 || item.GiftValue != 300 {
t.Fatalf("gift snapshot mismatch: %+v", item)
}
if err := walletMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRecordFromMetadataFallsBackToTotalValueForHistoricalUnitPrice(t *testing.T) {
item := recordFromMetadata(recordDTO{}, bizTypeDirectGiftDebit, giftMetadata{
GiftID: "legacy-gift",
GiftCount: 2,
ChargeAmount: 240,
SenderUserID: 1,
TargetUserID: 2,
DirectGift: true,
})
if item.Scene != sceneDirect || item.Gift.Name != "legacy-gift" || item.UnitValue != 120 || item.GiftValue != 240 {
t.Fatalf("historical fallback mismatch: %+v", item)
}
}
func TestListResolvesCommonSenderIdentityFilterBeforeIndexedWalletPage(t *testing.T) {
walletDB, walletMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new wallet sqlmock: %v", err)
}
defer walletDB.Close()
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new user sqlmock: %v", err)
}
defer userDB.Close()
// 短 ID 先在 user owner 库解析为内部 user_id钱包查询只使用生成列组合索引不在 metadata JSON 上做运行时扫描。
userMock.ExpectQuery("SELECT u.user_id").
WithArgs("fami", "888888", "888888", sqlmock.AnyArg(), "888888").
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(int64(1001)))
walletMock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM wallet_transactions WHERE app_code = ? AND status = ? AND biz_type IN (?, ?) AND gift_sender_user_id IN (?)")).
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit, int64(1001)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
walletMock.ExpectQuery("SELECT transaction_id, biz_type, COALESCE\\(CAST\\(metadata_json AS CHAR\\), '\\{\\}'\\), created_at_ms").
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit, int64(1001), 30, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "biz_type", "metadata_json", "created_at_ms"}))
items, total, err := NewService(userDB, walletDB).List(context.Background(), "fami", listQuery{
Page: 1,
PageSize: 30,
SenderFilter: shared.UserIdentityFilter{
DisplayUserID: "888888",
},
})
if err != nil {
t.Fatalf("list gift records by sender: %v", err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("unexpected filtered page total=%d items=%d", total, len(items))
}
if err := walletMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -122,6 +122,22 @@ type tierRequest struct {
DisplayConfigJSON *string `json:"display_config_json"` DisplayConfigJSON *string `json:"display_config_json"`
} }
type batchLevelConfigRequest struct {
Rules []batchRuleRequest `json:"rules"`
Tiers []batchTierRequest `json:"tiers"`
}
type batchRuleRequest struct {
Track string `json:"track"`
Level int32 `json:"level"`
ruleRequest
}
type batchTierRequest struct {
TierID int64 `json:"tier_id"`
tierRequest
}
// ListLevelConfig 返回用户管理 > 等级配置页面需要的三条等级轨道完整配置。 // ListLevelConfig 返回用户管理 > 等级配置页面需要的三条等级轨道完整配置。
func (h *Handler) ListLevelConfig(c *gin.Context) { func (h *Handler) ListLevelConfig(c *gin.Context) {
track := normalizeTrack(c.Query("track")) track := normalizeTrack(c.Query("track"))
@ -340,6 +356,166 @@ func (h *Handler) UpdateTier(c *gin.Context) {
h.upsertTier(c, tierID) h.upsertTier(c, tierID)
} }
// BatchUpsertLevelConfig 把上下两张配置表的一次编辑合并提交;任一行失败时 activity-service 会整体回滚。
func (h *Handler) BatchUpsertLevelConfig(c *gin.Context) {
var req batchLevelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil || (len(req.Rules) == 0 && len(req.Tiers) == 0) || len(req.Rules)+len(req.Tiers) > 500 {
response.BadRequest(c, "批量等级配置参数不正确")
return
}
config, err := h.loadConfig(c, "", "")
if err != nil {
response.ServerError(c, "读取等级配置失败")
return
}
grpcRequest := &activityv1.BatchUpsertLevelConfigRequest{
Meta: h.meta(c), Rules: make([]*activityv1.LevelRule, 0, len(req.Rules)), Tiers: make([]*activityv1.LevelTier, 0, len(req.Tiers)),
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
validatedResources := make(map[string]error)
for _, input := range req.Rules {
track := normalizeTrack(input.Track)
current, exists := findRule(config.GetRules(), track, input.Level)
if !validTrack(track) || input.Level < 0 || !exists {
response.BadRequest(c, "批量编辑包含不存在的等级规则")
return
}
item, err := h.batchRuleProto(c, track, input.Level, current, input.ruleRequest, validatedResources)
if err != nil {
response.BadRequest(c, err.Error())
return
}
grpcRequest.Rules = append(grpcRequest.Rules, item)
}
for _, input := range req.Tiers {
current, exists := findTier(config.GetTiers(), input.TierID)
if input.TierID <= 0 || !exists {
response.BadRequest(c, "批量编辑包含不存在的等级层")
return
}
item, err := batchTierProto(input.TierID, current, input.tierRequest)
if err != nil {
response.BadRequest(c, err.Error())
return
}
grpcRequest.Tiers = append(grpcRequest.Tiers, item)
}
resp, err := h.activity.BatchUpsertLevelConfig(c.Request.Context(), grpcRequest)
if err != nil {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "batch-upsert-level-config", "growth_level_config", appctx.FromContext(c.Request.Context()), fmt.Sprintf("rules=%d tiers=%d", len(req.Rules), len(req.Tiers)))
response.OK(c, map[string]any{"rules": rulesFromProto(resp.GetRules()), "tiers": tiersFromProto(resp.GetTiers()), "server_time_ms": resp.GetServerTimeMs()})
}
func (h *Handler) batchRuleProto(c *gin.Context, track string, level int32, current *activityv1.LevelRule, req ruleRequest, validatedResources map[string]error) (*activityv1.LevelRule, error) {
requiredValue, name, status := current.GetRequiredValue(), current.GetName(), current.GetStatus()
rewardResourceGroupID, sortOrder := current.GetRewardResourceGroupId(), current.GetSortOrder()
if req.RequiredValue != nil {
requiredValue = *req.RequiredValue
}
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if req.Status != nil {
status = normalizeStatus(*req.Status)
}
if req.RewardResourceGroupID != nil {
rewardResourceGroupID = *req.RewardResourceGroupID
}
if req.SortOrder != nil {
sortOrder = *req.SortOrder
}
if requiredValue < 0 || name == "" || !validStatus(status) || rewardResourceGroupID < 0 {
return nil, fmt.Errorf("等级规则参数不正确")
}
displayConfig := current.GetDisplayConfigJson()
if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
}
displayConfig, err := mergeRuleDisplayResources(displayConfig, req.LongBadgeResourceID, req.AvatarFrameResourceID, req.ShortBadgeResourceID)
if err != nil {
return nil, fmt.Errorf("展示配置 JSON 不正确")
}
if err := h.validateBatchRuleResources(c, track, current.GetDisplayConfigJson(), displayConfig, req, validatedResources); err != nil {
return nil, err
}
return &activityv1.LevelRule{Track: track, Level: level, RequiredValue: requiredValue, Name: name, Status: status, RewardResourceGroupId: rewardResourceGroupID, SortOrder: sortOrder, DisplayConfigJson: displayConfig}, nil
}
func (h *Handler) validateBatchRuleResources(c *gin.Context, track string, currentDisplayConfig string, nextDisplayConfig string, req ruleRequest, cache map[string]error) error {
checks := []struct {
changed bool
kind string
id int64
check func(*gin.Context, string, int64) error
}{
{changed: req.LongBadgeResourceID != nil && longBadgeResourceID(currentDisplayConfig) != longBadgeResourceID(nextDisplayConfig), kind: "long-badge", id: longBadgeResourceID(nextDisplayConfig), check: h.validateLevelLongBadgeResource},
{changed: req.AvatarFrameResourceID != nil && avatarFrameResourceID(currentDisplayConfig) != avatarFrameResourceID(nextDisplayConfig), kind: "avatar-frame", id: avatarFrameResourceID(nextDisplayConfig), check: h.validateLevelAvatarFrameResource},
{changed: req.ShortBadgeResourceID != nil && shortBadgeResourceID(currentDisplayConfig) != shortBadgeResourceID(nextDisplayConfig), kind: "short-badge", id: shortBadgeResourceID(nextDisplayConfig), check: h.validateLevelShortBadgeResource},
}
for _, item := range checks {
if !item.changed || item.id <= 0 {
continue
}
key := fmt.Sprintf("%s:%s:%d", item.kind, track, item.id)
if cached, ok := cache[key]; ok {
if cached != nil {
return cached
}
continue
}
err := item.check(c, track, item.id)
cache[key] = err
if err != nil {
return err
}
}
return nil
}
func batchTierProto(tierID int64, current *activityv1.LevelTier, req tierRequest) (*activityv1.LevelTier, error) {
track, minLevel, maxLevel, name, status := current.GetTrack(), current.GetMinLevel(), current.GetMaxLevel(), current.GetName(), current.GetStatus()
avatarFrameID, badgeID, rewardGroupID := current.GetDisplayAvatarFrameResourceId(), current.GetDisplayBadgeResourceId(), current.GetRewardResourceGroupId()
if req.Track != nil {
track = normalizeTrack(*req.Track)
}
if req.MinLevel != nil {
minLevel = *req.MinLevel
}
if req.MaxLevel != nil {
maxLevel = *req.MaxLevel
}
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if req.Status != nil {
status = normalizeStatus(*req.Status)
}
if req.DisplayAvatarFrameResourceID != nil {
avatarFrameID = *req.DisplayAvatarFrameResourceID
}
if req.DisplayBadgeResourceID != nil {
badgeID = *req.DisplayBadgeResourceID
}
if req.RewardResourceGroupID != nil {
rewardGroupID = *req.RewardResourceGroupID
}
if !validTrack(track) || minLevel < 0 || maxLevel < minLevel || name == "" || !validStatus(status) || avatarFrameID < 0 || badgeID < 0 || rewardGroupID < 0 {
return nil, fmt.Errorf("等级层参数不正确")
}
displayConfig := current.GetDisplayConfigJson()
if req.DisplayConfigJSON != nil {
displayConfig = strings.TrimSpace(*req.DisplayConfigJSON)
}
displayConfig, err := normalizeJSONObject(displayConfig)
if err != nil {
return nil, fmt.Errorf("展示配置 JSON 不正确")
}
return &activityv1.LevelTier{TierId: tierID, Track: track, MinLevel: minLevel, MaxLevel: maxLevel, Name: name, DisplayAvatarFrameResourceId: avatarFrameID, DisplayBadgeResourceId: badgeID, RewardResourceGroupId: rewardGroupID, Status: status, DisplayConfigJson: displayConfig}, nil
}
// upsertTier 复用已有等级段配置能力,保持 1-10、11-20 这类展示徽章仍由 activity-service 拥有。 // upsertTier 复用已有等级段配置能力,保持 1-10、11-20 这类展示徽章仍由 activity-service 拥有。
func (h *Handler) upsertTier(c *gin.Context, tierID int64) { func (h *Handler) upsertTier(c *gin.Context, tierID int64) {
var req tierRequest var req tierRequest
@ -688,6 +864,22 @@ func tierFromProto(item *activityv1.LevelTier) tierDTO {
} }
} }
func rulesFromProto(items []*activityv1.LevelRule) []ruleDTO {
result := make([]ruleDTO, 0, len(items))
for _, item := range items {
result = append(result, ruleFromProto(item))
}
return result
}
func tiersFromProto(items []*activityv1.LevelTier) []tierDTO {
result := make([]tierDTO, 0, len(items))
for _, item := range items {
result = append(result, tierFromProto(item))
}
return result
}
func findTrack(items []*activityv1.LevelTrack, track string) (*activityv1.LevelTrack, bool) { func findTrack(items []*activityv1.LevelTrack, track string) (*activityv1.LevelTrack, bool) {
for _, item := range items { for _, item := range items {
if item.GetTrack() == track { if item.GetTrack() == track {

View File

@ -14,6 +14,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/users/level-config", middleware.RequirePermission("level-config:view"), h.ListLevelConfig) protected.GET("/admin/users/level-config", middleware.RequirePermission("level-config:view"), h.ListLevelConfig)
protected.PUT("/admin/users/level-config/tracks/:track", middleware.RequirePermission("level-config:update"), h.UpdateTrack) protected.PUT("/admin/users/level-config/tracks/:track", middleware.RequirePermission("level-config:update"), h.UpdateTrack)
protected.PUT("/admin/users/level-config/rules/:track/:level", middleware.RequirePermission("level-config:update"), h.UpsertRule) protected.PUT("/admin/users/level-config/rules/:track/:level", middleware.RequirePermission("level-config:update"), h.UpsertRule)
protected.PUT("/admin/users/level-config/batch", middleware.RequirePermission("level-config:update"), h.BatchUpsertLevelConfig)
protected.POST("/admin/users/level-config/tiers", middleware.RequirePermission("level-config:update"), h.CreateTier) protected.POST("/admin/users/level-config/tiers", middleware.RequirePermission("level-config:update"), h.CreateTier)
protected.PUT("/admin/users/level-config/tiers/:tier_id", middleware.RequirePermission("level-config:update"), h.UpdateTier) protected.PUT("/admin/users/level-config/tiers/:tier_id", middleware.RequirePermission("level-config:update"), h.UpdateTier)
} }

View File

@ -39,8 +39,30 @@ type financeCoinSellerRechargeStats struct {
const ( const (
financeCoinSellerRechargeMaxPageSize = 100 financeCoinSellerRechargeMaxPageSize = 100
financeRechargeMergeMaxPrefetch = 5000 financeRechargeMergeMaxPrefetch = 5000
// 2^53 以内的 int64 在 float64 里精确可表示,不会被浏览器 JSON 圆整;
// 只有 legacy 合成/雪花区域 ID(远大于 2^53)需要按目录归一后才能做 SQL 精确等值。
maxExactFloat64RegionID = int64(1) << 53
) )
// normalizeFinanceCoinSellerRegionID 把请求里可能被前端 float64 圆整过的 legacy 区域 ID
// 还原成区域目录里的精确值;目录不可用或没有对应项时原样返回(与 databi normalizeRegionID 同语义)。
func (h *Handler) normalizeFinanceCoinSellerRegionID(ctx context.Context, appCode string, regionID int64) int64 {
if h == nil || regionID <= maxExactFloat64RegionID {
return regionID
}
regions, err := h.listRechargeBillRegions(ctx, appCode)
if err != nil {
return regionID
}
for _, region := range regions {
if repository.RegionIDLooseEqual(region.RegionID, regionID) {
return region.RegionID
}
}
return regionID
}
func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, summary *rechargeBillSummaryDTO) error { func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, summary *rechargeBillSummaryDTO) error {
if summary == nil { if summary == nil {
return nil return nil
@ -122,6 +144,7 @@ func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode st
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter { if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return stats, nil return stats, nil
} }
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
if h != nil && h.store != nil { if h != nil && h.store != nil {
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{ repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode, AppCode: appCode,
@ -258,6 +281,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter { if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return []rechargeBillDTO{}, 0, nil return []rechargeBillDTO{}, 0, nil
} }
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize) page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize) prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*2) items := make([]rechargeBillDTO, 0, prefetch*2)

View File

@ -1,6 +1,7 @@
package payment package payment
import ( import (
"context"
"testing" "testing"
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
@ -21,3 +22,20 @@ func TestFinanceCoinSellerRechargeOrderBillUsesBusinessTime(t *testing.T) {
t.Fatalf("finance bill must use selected business time: %+v", bill) t.Fatalf("finance bill must use selected business time: %+v", bill)
} }
} }
func TestNormalizeFinanceCoinSellerRegionIDRestoresRoundedLegacyID(t *testing.T) {
handler := New(&mockPaymentWallet{}, nil, nil, nil, nil, WithMoneyRegionSources(&staticMoneyRegionSource{
regions: []moneyRegionDTO{{AppCode: "aslan", RegionID: 2049040141349486594, RegionCode: "IN", Name: "印度"}},
}))
// 浏览器 JSON 把 2049040141349486594 圆整成 ...600SQL 精确等值前必须先还原成目录精确值。
if got := handler.normalizeFinanceCoinSellerRegionID(context.Background(), "aslan", 2049040141349486600); got != 2049040141349486594 {
t.Fatalf("rounded legacy region id should normalize to catalog value, got %d", got)
}
// 目录没有的 ID 原样返回2^53 以内的 hyapp 区域 ID 不查目录直接返回。
if got := handler.normalizeFinanceCoinSellerRegionID(context.Background(), "aslan", 9_100_000_000_000_000_001); got != 9_100_000_000_000_000_001 {
t.Fatalf("unknown region id should stay unchanged, got %d", got)
}
if got := handler.normalizeFinanceCoinSellerRegionID(context.Background(), "lalu", 10); got != 10 {
t.Fatalf("small region id should stay unchanged, got %d", got)
}
}

View File

@ -240,6 +240,11 @@ func (s *MongoRechargeBillSource) AppName() string {
return s.config.AppCode return s.config.AppCode
} }
// LogoURL 与 legacy 账单源同配置生命周期;这类 App 不是 HyApp gateway 租户,不为了展示 Logo 向 apps 注册表塞入虚假 active 记录。
func (s *MongoRechargeBillSource) LogoURL() string {
return strings.TrimSpace(s.config.LogoURL)
}
type legacyCoinSellerRechargeStats struct { type legacyCoinSellerRechargeStats struct {
TotalUSDMinor int64 TotalUSDMinor int64
DailyUSDMinor map[string]int64 DailyUSDMinor map[string]int64

View File

@ -16,6 +16,7 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig {
Enabled: true, Enabled: true,
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
MongoDatabase: "test", MongoDatabase: "test",
MongoCollection: "in_app_purchase_details", MongoCollection: "in_app_purchase_details",
@ -23,6 +24,13 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig {
} }
} }
func TestLegacyRechargeBillSourceExposesConfiguredLogoURL(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
if source.LogoURL() != "https://media.haiyihy.com/admin/apps/logos/yumi.png" {
t.Fatalf("logo url=%q", source.LogoURL())
}
}
func aslanBillSourceConfig() config.FinanceBillSourceConfig { func aslanBillSourceConfig() config.FinanceBillSourceConfig {
cfg := yumiBillSourceConfig() cfg := yumiBillSourceConfig()
cfg.AppCode = "aslan" cfg.AppCode = "aslan"

View File

@ -46,6 +46,7 @@ type moneyAppDTO struct {
AppID int64 `json:"appId"` AppID int64 `json:"appId"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
PackageName string `json:"packageName"` PackageName string `json:"packageName"`
Platform string `json:"platform"` Platform string `json:"platform"`
Status string `json:"status"` Status string `json:"status"`
@ -456,7 +457,7 @@ func (h *Handler) loadMoneyMasterData(ctx context.Context, access repository.Mon
func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, error) { func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, error) {
where, args := scopedAppWhere(access) where, args := scopedAppWhere(access)
rows, err := h.userDB.QueryContext(ctx, ` rows, err := h.userDB.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status SELECT app_id, app_code, app_name, logo_url, package_name, platform, status
FROM apps FROM apps
WHERE status = 'active'`+where+` WHERE status = 'active'`+where+`
ORDER BY app_name ASC, app_code ASC`, args...) ORDER BY app_name ASC, app_code ASC`, args...)
@ -467,7 +468,7 @@ func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAcce
items := []moneyAppDTO{} items := []moneyAppDTO{}
for rows.Next() { for rows.Next() {
var item moneyAppDTO var item moneyAppDTO
if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.PackageName, &item.Platform, &item.Status); err != nil { if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.LogoURL, &item.PackageName, &item.Platform, &item.Status); err != nil {
return nil, err return nil, err
} }
items = append(items, item) items = append(items, item)

View File

@ -160,6 +160,56 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
return nil, false, nil return nil, false, nil
} }
// RegionIDForCountryCode 把 legacy 用户的国家码解析成该 App 的区域 ID。
// 遍历顺序与 ListMoneyMasterData 相同createTime 倒序、先到先得),
// 与账单区域分布的 legacyCountryRegionIndex 保持同一优先级,保证同一个国家
// 在账单归因和币商订单归因里落进同一个区域(如 MM 同时挂在多个区域时)。
func (s *MongoMoneyRegionSource) RegionIDForCountryCode(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != appctx.Normalize(appCode) {
return 0, false, nil
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" {
return 0, false, nil
}
_, regions, _, err := s.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return 0, false, err
}
return regionIDForCountryCodeInRegions(regions, countryCode)
}
func regionIDForCountryCodeInRegions(regions []moneyRegionDTO, countryCode string) (int64, bool, error) {
for _, region := range regions {
for _, item := range region.Countries {
if item == countryCode {
return region.RegionID, true, nil
}
}
}
return 0, false, nil
}
// RegionIDForCountryCode 在多个区域源中解析国家码归属;一个 App 只会命中一个源。
func RegionIDForCountryCode(ctx context.Context, sources []MoneyRegionSource, appCode string, countryCode string) (int64, bool, error) {
for _, source := range sources {
resolver, ok := source.(interface {
RegionIDForCountryCode(ctx context.Context, appCode string, countryCode string) (int64, bool, error)
})
if !ok {
continue
}
regionID, found, err := resolver.RegionIDForCountryCode(ctx, appCode, countryCode)
if err != nil {
return 0, false, err
}
if found {
return regionID, true, nil
}
}
return 0, false, nil
}
// RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态; // RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态;
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。 // RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
type RegionCatalogEntry struct { type RegionCatalogEntry struct {
@ -253,8 +303,10 @@ func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfi
} }
} }
return []moneyAppDTO{{ return []moneyAppDTO{{
AppCode: appCode, AppCode: appCode,
AppName: appName, AppName: appName,
// Legacy App 没有 apps 行;其 Logo 与区域源一起配置,避免为了 UI 展示伪造活跃租户。
LogoURL: strings.TrimSpace(sourceConfig.LogoURL),
Platform: "legacy", Platform: "legacy",
Status: "active", Status: "active",
}}, regions, countries, nil }}, regions, countries, nil

View File

@ -24,6 +24,7 @@ func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
cfg := config.MoneyRegionSourceConfig{ cfg := config.MoneyRegionSourceConfig{
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.example.com/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
} }
apps, regions, countries, err := legacyRegionDocumentsToMoneyData(cfg, []legacyRegionDocument{ apps, regions, countries, err := legacyRegionDocumentsToMoneyData(cfg, []legacyRegionDocument{
@ -35,7 +36,7 @@ func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("legacy money data failed: %v", err) t.Fatalf("legacy money data failed: %v", err)
} }
if len(apps) != 1 || apps[0].AppCode != "yumi" || apps[0].AppName != "Yumi" || apps[0].Platform != "legacy" { if len(apps) != 1 || apps[0].AppCode != "yumi" || apps[0].AppName != "Yumi" || apps[0].LogoURL != cfg.LogoURL || apps[0].Platform != "legacy" {
t.Fatalf("apps mismatch: %+v", apps) t.Fatalf("apps mismatch: %+v", apps)
} }
if len(regions) != 2 || regions[0].RegionID < legacySyntheticRegionIDBase || regions[0].RegionCode != "OTHER" || regions[1].RegionID != 1001 || regions[1].RegionCode != "MIDDLE_EAST" { if len(regions) != 2 || regions[0].RegionID < legacySyntheticRegionIDBase || regions[0].RegionCode != "OTHER" || regions[1].RegionID != 1001 || regions[1].RegionCode != "MIDDLE_EAST" {
@ -159,7 +160,7 @@ func newMoneyRegionSourceUserSQLMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock, fu
} }
func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) { func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) {
query := mock.ExpectQuery(`(?s)SELECT app_id, app_code, app_name, package_name, platform, status\s+FROM apps\s+WHERE status = 'active'`) query := mock.ExpectQuery(`(?s)SELECT app_id, app_code, app_name, logo_url, package_name, platform, status\s+FROM apps\s+WHERE status = 'active'`)
if len(appCodes) > 0 { if len(appCodes) > 0 {
args := make([]driver.Value, 0, len(appCodes)) args := make([]driver.Value, 0, len(appCodes))
for _, appCode := range appCodes { for _, appCode := range appCodes {
@ -167,12 +168,63 @@ func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) {
} }
query.WithArgs(args...) query.WithArgs(args...)
} }
query.WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "package_name", "platform", "status"})) query.WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "logo_url", "package_name", "platform", "status"}))
} }
func TestMoneyRegionSourceConfigDefaults(t *testing.T) { func TestMoneyRegionSourceConfigDefaults(t *testing.T) {
cfg := config.Default() cfg := config.Default()
if len(cfg.MoneyRegionSources) != 2 || cfg.MoneyRegionSources[0].RequestTimeout != 5*time.Second || cfg.MoneyRegionSources[1].AppCode != "aslan" { if len(cfg.MoneyRegionSources) != 2 || cfg.MoneyRegionSources[0].RequestTimeout != 5*time.Second || cfg.MoneyRegionSources[0].LogoURL == "" || cfg.MoneyRegionSources[1].AppCode != "aslan" {
t.Fatalf("default money region source mismatch: %+v", cfg.MoneyRegionSources) t.Fatalf("default money region source mismatch: %+v", cfg.MoneyRegionSources)
} }
} }
func TestRegionIDForCountryCodeInRegionsFirstMatchWins(t *testing.T) {
// 目录顺序即 createTime 倒序MM 同时挂在缅甸区和巴基斯坦区时必须命中最新的(与账单
// legacyCountryRegionIndex 同优先级),否则同一用户的账单和币商订单会归进两个区域。
regions := []moneyRegionDTO{
{AppCode: "aslan", RegionID: 2064044089182887937, RegionCode: "MM", Countries: []string{"MM"}},
{AppCode: "aslan", RegionID: 2049040320781811714, RegionCode: "PK", Countries: []string{"PK", "MM"}},
}
regionID, found, err := regionIDForCountryCodeInRegions(regions, "MM")
if err != nil || !found || regionID != 2064044089182887937 {
t.Fatalf("MM should resolve to the newest region: id=%d found=%v err=%v", regionID, found, err)
}
regionID, found, err = regionIDForCountryCodeInRegions(regions, "PK")
if err != nil || !found || regionID != 2049040320781811714 {
t.Fatalf("PK region mismatch: id=%d found=%v err=%v", regionID, found, err)
}
if _, found, _ = regionIDForCountryCodeInRegions(regions, "BR"); found {
t.Fatalf("unmapped country should not resolve")
}
}
type staticCountryRegionResolver struct {
staticMoneyRegionSource
appCode string
}
func (s *staticCountryRegionResolver) RegionIDForCountryCode(_ context.Context, appCode string, countryCode string) (int64, bool, error) {
if appCode != s.appCode {
return 0, false, nil
}
return regionIDForCountryCodeInRegions(s.regions, strings.ToUpper(strings.TrimSpace(countryCode)))
}
func TestRegionIDForCountryCodeSkipsSourcesWithoutResolver(t *testing.T) {
sources := []MoneyRegionSource{
&staticMoneyRegionSource{},
&staticCountryRegionResolver{
appCode: "yumi",
staticMoneyRegionSource: staticMoneyRegionSource{
regions: []moneyRegionDTO{{AppCode: "yumi", RegionID: 1002, RegionCode: "INDIA", Countries: []string{"IN"}}},
},
},
}
regionID, found, err := RegionIDForCountryCode(context.Background(), sources, "yumi", "IN")
if err != nil || !found || regionID != 1002 {
t.Fatalf("cross-source resolve mismatch: id=%d found=%v err=%v", regionID, found, err)
}
if _, found, _ = RegionIDForCountryCode(context.Background(), sources, "aslan", "IN"); found {
t.Fatalf("other app should not resolve")
}
}

View File

@ -0,0 +1,47 @@
package payment
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func TestListRechargeBillAppsReturnsStoredLogoURL(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectQuery(`SELECT app_code, app_name, logo_url`).
WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).
AddRow("lalu", "Lalu", "https://media.haiyihy.com/admin/apps/logos/lalu.png"))
recorder := httptest.NewRecorder()
// 直接使用 recorder 承载 Gin 响应,测试只关注 App 目录映射,不绕过真实 handler 序列化。
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest("GET", "/api/v1/admin/payment/recharge-apps", nil)
(&Handler{userDB: db, billSources: map[string]RechargeBillSource{}}).ListRechargeBillApps(context)
if recorder.Code != 200 {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var payload struct {
Data struct {
Items []rechargeBillAppDTO `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Data.Items) != 1 || payload.Data.Items[0].LogoURL != "https://media.haiyihy.com/admin/apps/logos/lalu.png" {
t.Fatalf("unexpected apps: %+v", payload.Data.Items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -279,6 +279,12 @@ func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
type rechargeBillAppDTO struct { type rechargeBillAppDTO struct {
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
}
// rechargeBillSourceLogo 是兼容扩展:历史或测试账单源不实现时仍可列出,新的 Mongo 源则补充 Logo。
type rechargeBillSourceLogo interface {
LogoURL() string
} }
// ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。 // ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。
@ -287,7 +293,7 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
seen := map[string]struct{}{} seen := map[string]struct{}{}
if h.userDB != nil { if h.userDB != nil {
rows, err := h.userDB.QueryContext(c.Request.Context(), ` rows, err := h.userDB.QueryContext(c.Request.Context(), `
SELECT app_code, app_name SELECT app_code, app_name, logo_url
FROM apps FROM apps
WHERE status = 'active' WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC`) ORDER BY app_name ASC, app_code ASC`)
@ -298,7 +304,7 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var item rechargeBillAppDTO var item rechargeBillAppDTO
if err := rows.Scan(&item.AppCode, &item.AppName); err != nil { if err := rows.Scan(&item.AppCode, &item.AppName, &item.LogoURL); err != nil {
response.ServerError(c, "获取 App 列表失败") response.ServerError(c, "获取 App 列表失败")
return return
} }
@ -317,7 +323,11 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
if _, ok := seen[appCode]; ok { if _, ok := seen[appCode]; ok {
continue continue
} }
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName()}) logoURL := ""
if logoSource, ok := source.(rechargeBillSourceLogo); ok {
logoURL = strings.TrimSpace(logoSource.LogoURL())
}
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName(), LogoURL: logoURL})
} }
sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName }) sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName })
items = append(items, legacyApps...) items = append(items, legacyApps...)

View File

@ -6,31 +6,33 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.RouterGroup, h *Handler) {
if h == nil { if h == nil {
return return
} }
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills) workspaceProtected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary) workspaceProtected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
protected.GET("/admin/payment/recharge-bills/overview", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillOverview) workspaceProtected.GET("/admin/payment/recharge-bills/overview", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillOverview)
protected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:view"), h.ExportRechargeBills) // 导出、刷新和配置写操作必须与列表查看解耦,避免只读产品角色获得副作用入口。
protected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:view"), h.RefreshGoogleRechargePaidDetails) workspaceProtected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:export"), h.ExportRechargeBills)
protected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions) workspaceProtected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:refresh"), h.RefreshGoogleRechargePaidDetails)
protected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps) workspaceProtected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels) workspaceProtected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
protected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope) workspaceProtected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope) workspaceProtected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance) workspaceProtected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks) workspaceProtected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
protected.POST("/admin/payment/temporary-links", middleware.RequirePermission(temporaryPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink) workspaceProtected.POST("/admin/payment/temporary-links", middleware.RequirePermission(temporaryPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink) workspaceProtected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-methods/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentMethods) appProtected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate) appProtected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequireAnyPermission("payment-third-party:update-method", "payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-rates/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentRates) appProtected.POST("/admin/payment/third-party-methods/sync", middleware.RequireAnyPermission("payment-third-party:sync-methods", "payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts) appProtected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequireAnyPermission("payment-third-party:update-rate", "payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct) appProtected.POST("/admin/payment/third-party-rates/sync", middleware.RequireAnyPermission("payment-third-party:sync-rates", "payment-third-party:update"), h.SyncThirdPartyPaymentRates)
protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct) appProtected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
protected.DELETE("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:delete"), h.DeleteRechargeProduct) appProtected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
appProtected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)
appProtected.DELETE("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:delete"), h.DeleteRechargeProduct)
} }

View File

@ -34,22 +34,23 @@ func UserDTO(user model.User) map[string]any {
teamName := UserTeamName(user) teamName := UserTeamName(user)
return map[string]any{ return map[string]any{
"id": user.ID, "id": user.ID,
"account": user.Username, "account": user.Username,
"username": user.Username, "username": user.Username,
"name": user.Name, "name": user.Name,
"team": teamName, "team": teamName,
"teamId": user.TeamID, "teamId": user.TeamID,
"teamName": teamName, "teamName": teamName,
"status": user.Status, "status": user.Status,
"mfa": BoolText(user.MFAEnabled), "appScopeMode": user.AppScopeMode,
"mfaEnabled": user.MFAEnabled, "mfa": BoolText(user.MFAEnabled),
"lastLogin": lastLogin, "mfaEnabled": user.MFAEnabled,
"lastLoginMs": user.LastLoginAtMS, "lastLogin": lastLogin,
"roles": user.Roles, "lastLoginMs": user.LastLoginAtMS,
"role": role, "roles": user.Roles,
"roleIds": roleIDs, "role": role,
"permissions": PermissionsForUser(user), "roleIds": roleIDs,
"permissions": PermissionsForUser(user),
} }
} }

View File

@ -0,0 +1,130 @@
package repository
import (
"errors"
"sort"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
)
type AppAccess struct {
Mode string
AppCodes []string
}
func (access AppAccess) Allows(appCode string) bool {
appCode = strings.ToLower(strings.TrimSpace(appCode))
if appCode == "" {
return false
}
if access.Mode == model.UserAppScopeModeAll {
return true
}
for _, allowed := range access.AppCodes {
if allowed == appCode {
return true
}
}
return false
}
func (s *Store) AppAccessForUser(userID uint) (AppAccess, error) {
if userID == 0 {
return AppAccess{Mode: model.UserAppScopeModeNone}, nil
}
var user model.User
if err := s.db.Select("id", "app_scope_mode").First(&user, userID).Error; err != nil {
return AppAccess{}, err
}
mode := normalizeAppScopeMode(user.AppScopeMode)
if mode != model.UserAppScopeModeSelected {
return AppAccess{Mode: mode}, nil
}
var scopes []model.UserAppScope
if err := s.db.Where("user_id = ?", userID).Order("app_code ASC").Find(&scopes).Error; err != nil {
return AppAccess{}, err
}
appCodes := make([]string, 0, len(scopes))
for _, scope := range scopes {
appCodes = append(appCodes, scope.AppCode)
}
return AppAccess{Mode: mode, AppCodes: appCodes}, nil
}
func (s *Store) ReplaceUserAppScopes(userID uint, mode string, appCodes []string) (AppAccess, error) {
if userID == 0 {
return AppAccess{}, errors.New("user id is required")
}
rawMode := strings.ToLower(strings.TrimSpace(mode))
if rawMode != model.UserAppScopeModeAll && rawMode != model.UserAppScopeModeSelected && rawMode != model.UserAppScopeModeNone {
return AppAccess{}, errors.New("app scope mode must be all, selected, or none")
}
mode = rawMode
normalizedCodes := normalizeAppCodes(appCodes)
if mode == model.UserAppScopeModeSelected && len(normalizedCodes) == 0 {
return AppAccess{}, errors.New("selected app scope requires app codes")
}
if mode != model.UserAppScopeModeSelected {
normalizedCodes = nil
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.First(&user, userID).Error; err != nil {
return err
}
// 先删除旧明细再更新模式,使 all/none 不残留会被未来代码误读的 selected 行。
if err := tx.Where("user_id = ?", userID).Delete(&model.UserAppScope{}).Error; err != nil {
return err
}
if len(normalizedCodes) > 0 {
scopes := make([]model.UserAppScope, 0, len(normalizedCodes))
for _, appCode := range normalizedCodes {
scopes = append(scopes, model.UserAppScope{UserID: userID, AppCode: appCode})
}
if err := tx.Create(&scopes).Error; err != nil {
return err
}
}
return tx.Model(&user).Update("app_scope_mode", mode).Error
})
if err != nil {
return AppAccess{}, err
}
return AppAccess{Mode: mode, AppCodes: normalizedCodes}, nil
}
func normalizeAppScopeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case model.UserAppScopeModeAll:
return model.UserAppScopeModeAll
case model.UserAppScopeModeSelected:
return model.UserAppScopeModeSelected
default:
return model.UserAppScopeModeNone
}
}
func normalizeAppCodes(appCodes []string) []string {
seen := make(map[string]struct{}, len(appCodes))
out := make([]string, 0, len(appCodes))
for _, value := range appCodes {
// appctx.Normalize 会把空值回退 laluApp scope 的空值代表无授权,必须先独立过滤。
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
value = appctx.Normalize(value)
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}

View File

@ -0,0 +1,68 @@
package repository
import (
"testing"
"hyapp-admin-server/internal/model"
"github.com/DATA-DOG/go-sqlmock"
)
func TestAppAccessForSelectedUser(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT `id`,`app_scope_mode` FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "app_scope_mode"}).AddRow(7, model.UserAppScopeModeSelected))
mock.ExpectQuery("SELECT \\* FROM `admin_user_app_scopes` WHERE user_id = \\? ORDER BY app_code ASC").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "app_code", "created_at_ms", "updated_at_ms"}).
AddRow(1, 7, "huwaa", int64(1), int64(1)).
AddRow(2, 7, "lalu", int64(1), int64(1)))
access, err := store.AppAccessForUser(7)
if err != nil {
t.Fatalf("get app access: %v", err)
}
if !access.Allows("LALU") || !access.Allows("huwaa") || access.Allows("fami") {
t.Fatalf("unexpected selected access: %+v", access)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestReplaceUserAppScopesNormalizesSelectedCodes(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "app_scope_mode"}).AddRow(9, model.UserAppScopeModeNone))
mock.ExpectExec("DELETE FROM `admin_user_app_scopes` WHERE user_id = \\?").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("INSERT INTO `admin_user_app_scopes`").WillReturnResult(sqlmock.NewResult(1, 2))
mock.ExpectExec("UPDATE `admin_users` SET `app_scope_mode`=\\?,`updated_at_ms`=\\? WHERE `id` = \\?").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
access, err := store.ReplaceUserAppScopes(9, model.UserAppScopeModeSelected, []string{" LALU ", "huwaa", "lalu", ""})
if err != nil {
t.Fatalf("replace app scopes: %v", err)
}
if len(access.AppCodes) != 2 || access.AppCodes[0] != "huwaa" || access.AppCodes[1] != "lalu" {
t.Fatalf("unexpected normalized access: %+v", access)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestReplaceUserAppScopesRejectsEmptySelected(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
if _, err := store.ReplaceUserAppScopes(9, model.UserAppScopeModeSelected, nil); err == nil {
t.Fatal("expected selected mode without app codes to fail")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -1,127 +0,0 @@
package repository
import (
"errors"
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrFinanceApplicationAlreadyAudited = errors.New("finance application already audited")
type FinanceApplicationListOptions struct {
Page int
PageSize int
AppCode string
Operation string
Status string
Keyword string
ApplicantUserID uint
}
type FinanceApplicationAuditInput struct {
Decision string
AuditorUserID uint
AuditorName string
AuditRemark string
AuditedAtMS int64
WalletCommandID string
WalletTransactionID string
WalletAssetType string
WalletAmountDelta int64
WalletBalanceAfter int64
}
func (s *Store) CreateFinanceApplication(application *model.FinanceApplication) error {
if application == nil {
return errors.New("finance application is required")
}
return s.db.Create(application).Error
}
func (s *Store) GetFinanceApplication(id uint) (*model.FinanceApplication, error) {
var application model.FinanceApplication
if err := s.db.First(&application, id).Error; err != nil {
return nil, err
}
return &application, nil
}
func (s *Store) ListFinanceApplications(options FinanceApplicationListOptions) ([]model.FinanceApplication, int64, error) {
page, pageSize := normalizePage(options.Page, options.PageSize)
query := s.db.Model(&model.FinanceApplication{})
query = applyFinanceApplicationFilters(query, options)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var applications []model.FinanceApplication
err := query.
Order("created_at_ms DESC, id DESC").
Limit(pageSize).
Offset((page - 1) * pageSize).
Find(&applications).Error
return applications, total, err
}
func (s *Store) AuditFinanceApplication(id uint, input FinanceApplicationAuditInput) (*model.FinanceApplication, error) {
if id == 0 {
return nil, errors.New("finance application id is required")
}
if input.Decision != model.FinanceApplicationStatusApproved && input.Decision != model.FinanceApplicationStatusRejected {
return nil, errors.New("finance application decision is invalid")
}
var application model.FinanceApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
// 审批是一次性状态流转:先用行锁读取当前状态,再只允许 pending 进入终态,防止两个财务并发点击导致后写覆盖先写。
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&application, id).Error; err != nil {
return err
}
if application.Status != model.FinanceApplicationStatusPending {
return ErrFinanceApplicationAlreadyAudited
}
return tx.Model(&application).Updates(map[string]any{
"status": input.Decision,
"auditor_user_id": input.AuditorUserID,
"auditor_name": strings.TrimSpace(input.AuditorName),
"audit_remark": strings.TrimSpace(input.AuditRemark),
"audited_at_ms": input.AuditedAtMS,
"wallet_command_id": strings.TrimSpace(input.WalletCommandID),
"wallet_transaction_id": strings.TrimSpace(input.WalletTransactionID),
"wallet_asset_type": strings.TrimSpace(input.WalletAssetType),
"wallet_amount_delta": input.WalletAmountDelta,
"wallet_balance_after": input.WalletBalanceAfter,
"updated_at_ms": input.AuditedAtMS,
}).Error
})
if err != nil {
return nil, err
}
return s.GetFinanceApplication(id)
}
func applyFinanceApplicationFilters(query *gorm.DB, options FinanceApplicationListOptions) *gorm.DB {
if options.ApplicantUserID > 0 {
// “我的申请”接口只允许服务端用登录态限定申请人,不能信任前端传申请人 ID避免普通发起人越权查看他人财务申请。
query = query.Where("applicant_user_id = ?", options.ApplicantUserID)
}
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
query = query.Where("app_code = ?", appCode)
}
if operation := strings.TrimSpace(options.Operation); operation != "" {
query = query.Where("operation = ?", operation)
}
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
query = query.Where("status = ?", status)
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("target_user_id LIKE ? OR applicant_name LIKE ? OR auditor_name LIKE ? OR CAST(applicant_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like)
}
return query
}

View File

@ -1,57 +0,0 @@
package repository
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestListFinanceApplicationsFiltersByApplicant(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_finance_applications`").
WithArgs(uint(7), "pending").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery("SELECT \\* FROM `admin_finance_applications`").
WithArgs(uint(7), "pending", 50).
WillReturnRows(sqlmock.NewRows([]string{
"id",
"app_code",
"operation",
"wallet_identity",
"target_user_id",
"coin_amount",
"recharge_amount",
"credential_image_url",
"credential_text",
"applicant_user_id",
"applicant_name",
"auditor_user_id",
"auditor_name",
"status",
"audit_remark",
"audited_at_ms",
"created_at_ms",
"updated_at_ms",
}).AddRow(9, "lalu", "user_coin_credit", "", "10001", int64(100), "12.50", "", "receipt", 7, "ops", nil, "", "pending", "", nil, int64(1700000100000), int64(1700000100000)))
items, total, err := store.ListFinanceApplications(FinanceApplicationListOptions{
ApplicantUserID: 7,
Page: 1,
PageSize: 50,
Status: "pending",
})
if err != nil {
t.Fatalf("list finance applications failed: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("finance application page mismatch: total=%d items=%+v", total, items)
}
if items[0].ApplicantUserID != 7 || items[0].TargetUserID != "10001" {
t.Fatalf("finance application row mismatch: %+v", items[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}

View File

@ -37,9 +37,12 @@ type LegacyGooglePaidStats struct {
EstNetUSDMinor int64 EstNetUSDMinor int64
} }
// LegacyGooglePaidStatsByBillTime 按账单创建时间段聚合已同步实付:税/费/净收按实付占比折算到账单美金口径。 // LegacyGooglePaidStatsByBillTime 按账单创建时间段聚合实付同步覆盖度。
// synced_at_ms 是 Orders API 请求已成功落库的事实Google 作为 Merchant of Record 时可能不返回
// developerRevenueInBuyerCurrency此时 paid_net_micro=0 只表示缺少费用明细,不能把已同步订单重新算成未同步。
// 税/费/净收仍只对实付和净收都有效的订单按比例折算,避免用缺失字段伪造费用数据。
func (s *Store) LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64, endAtMS int64) (LegacyGooglePaidStats, error) { func (s *Store) LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64, endAtMS int64) (LegacyGooglePaidStats, error) {
where := "app_code = ? AND paid_amount_micro > 0 AND paid_net_micro > 0" where := "app_code = ?"
args := []any{strings.ToLower(strings.TrimSpace(appCode))} args := []any{strings.ToLower(strings.TrimSpace(appCode))}
if startAtMS > 0 { if startAtMS > 0 {
where += " AND bill_created_at_ms >= ?" where += " AND bill_created_at_ms >= ?"
@ -51,11 +54,14 @@ func (s *Store) LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64,
} }
var stats LegacyGooglePaidStats var stats LegacyGooglePaidStats
row := s.db.Raw(` row := s.db.Raw(`
SELECT COUNT(*), SELECT COALESCE(SUM(CASE WHEN synced_at_ms > 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(bill_usd_minor), 0), COALESCE(SUM(CASE WHEN paid_amount_micro > 0 AND paid_net_micro > 0 THEN bill_usd_minor ELSE 0 END), 0),
COALESCE(SUM(ROUND(bill_usd_minor * GREATEST(paid_amount_micro - paid_net_micro - paid_tax_micro, 0) / paid_amount_micro)), 0), COALESCE(SUM(CASE WHEN paid_amount_micro > 0 AND paid_net_micro > 0
COALESCE(SUM(ROUND(bill_usd_minor * paid_tax_micro / paid_amount_micro)), 0), THEN ROUND(bill_usd_minor * GREATEST(paid_amount_micro - paid_net_micro - paid_tax_micro, 0) / paid_amount_micro) ELSE 0 END), 0),
COALESCE(SUM(ROUND(bill_usd_minor * paid_net_micro / paid_amount_micro)), 0) COALESCE(SUM(CASE WHEN paid_amount_micro > 0 AND paid_net_micro > 0
THEN ROUND(bill_usd_minor * paid_tax_micro / paid_amount_micro) ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN paid_amount_micro > 0 AND paid_net_micro > 0
THEN ROUND(bill_usd_minor * paid_net_micro / paid_amount_micro) ELSE 0 END), 0)
FROM admin_legacy_google_paid_details FROM admin_legacy_google_paid_details
WHERE `+where, args...).Row() WHERE `+where, args...).Row()
if err := row.Scan(&stats.SyncedCount, &stats.CoveredUSDMinor, &stats.EstFeeUSDMinor, &stats.EstTaxUSDMinor, &stats.EstNetUSDMinor); err != nil { if err := row.Scan(&stats.SyncedCount, &stats.CoveredUSDMinor, &stats.EstFeeUSDMinor, &stats.EstTaxUSDMinor, &stats.EstNetUSDMinor); err != nil {

View File

@ -0,0 +1,33 @@
package repository
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestLegacyGooglePaidStatsCountsSyncedRowsWithoutNetRevenue(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
// 108 笔 Orders API 结果都已落库,但只有 2 笔带开发者净收。同步覆盖度必须仍为 108
// 金额估算则继续只覆盖净收有效的 2 笔,这正是 Google Merchant of Record 订单的线上边界。
mock.ExpectQuery(`(?s)SELECT.*SUM\(CASE WHEN synced_at_ms > 0 THEN 1 ELSE 0 END\).*CASE WHEN paid_amount_micro > 0 AND paid_net_micro > 0.*WHERE app_code = \? AND bill_created_at_ms >= \? AND bill_created_at_ms < \?`).
WithArgs("yumi", int64(1783872000000), int64(1783958400000)).
WillReturnRows(sqlmock.NewRows([]string{"synced_count", "covered_usd_minor", "fee_usd_minor", "tax_usd_minor", "net_usd_minor"}).
AddRow(108, 4200, 630, 210, 3360))
stats, err := store.LegacyGooglePaidStatsByBillTime(" YUMI ", 1783872000000, 1783958400000)
if err != nil {
t.Fatalf("load legacy google paid stats failed: %v", err)
}
if stats.SyncedCount != 108 {
t.Fatalf("synced count must use synced_at_ms instead of net revenue: %+v", stats)
}
if stats.CoveredUSDMinor != 4200 || stats.EstFeeUSDMinor != 630 || stats.EstTaxUSDMinor != 210 || stats.EstNetUSDMinor != 3360 {
t.Fatalf("settlement estimates mismatch: %+v", stats)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}

View File

@ -91,6 +91,12 @@ func (s *Store) ListUserMoneyScopes(userID uint) ([]model.UserMoneyScope, error)
return scopes, err return scopes, err
} }
func (s *Store) ListAllUserMoneyScopes() ([]model.UserMoneyScope, error) {
var scopes []model.UserMoneyScope
err := s.db.Order("app_code ASC, region_id ASC, user_id ASC").Find(&scopes).Error
return scopes, err
}
func (s *Store) ReplaceUserMoneyScopes(userID uint, scopes []model.UserMoneyScope) error { func (s *Store) ReplaceUserMoneyScopes(userID uint, scopes []model.UserMoneyScope) error {
if userID == 0 { if userID == 0 {
return errors.New("user id is required") return errors.New("user id is required")

View File

@ -60,6 +60,28 @@ func TestReplaceUserMoneyScopesRejectsInvalidScope(t *testing.T) {
} }
} }
func TestListAllUserMoneyScopes(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` ORDER BY app_code ASC, region_id ASC, user_id ASC").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"}).
AddRow(1, 7, "aslan", 2, int64(1700000000000), int64(1700000000000)).
AddRow(2, 9, "aslan", 5, int64(1700000000000), int64(1700000000000)).
AddRow(3, 9, "lalu", 0, int64(1700000000000), int64(1700000000000)))
scopes, err := store.ListAllUserMoneyScopes()
if err != nil {
t.Fatalf("list all money scopes failed: %v", err)
}
if len(scopes) != 3 || scopes[0].UserID != 7 || scopes[1].UserID != 9 || scopes[2].AppCode != "lalu" || scopes[2].RegionID != 0 {
t.Fatalf("all money scopes mismatch: %+v", scopes)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func newRepositorySQLMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) { func newRepositorySQLMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) {
t.Helper() t.Helper()
sqlDB, mock, err := sqlmock.New() sqlDB, mock, err := sqlmock.New()

View File

@ -76,33 +76,48 @@ func (s *Store) SyncDefaultPermissions() error {
if err := s.syncDefaultPermissions(tx); err != nil { if err := s.syncDefaultPermissions(tx); err != nil {
return err return err
} }
return s.syncDefaultRolePermissionMigrations(tx) return s.syncManagedDefaultRolePermissions(tx)
}) })
} }
func (s *Store) syncDefaultRolePermissionMigrations(tx *gorm.DB) error { func (s *Store) syncManagedDefaultRolePermissions(tx *gorm.DB) error {
var platformAdmin model.Role var allPermissions []model.Permission
err := tx.Where("code = ?", "platform-admin").First(&platformAdmin).Error if err := tx.Find(&allPermissions).Error; err != nil {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
if platformAdmin.ID > 0 {
var permissions []model.Permission
if err := tx.Find(&permissions).Error; err != nil {
return err
}
if err := tx.Model(&platformAdmin).Association("Permissions").Replace(permissions); err != nil {
return err
}
}
var opsAdmin model.Role for _, definition := range managedDefaultRoles {
err = tx.Where("code = ?", "ops-admin").First(&opsAdmin).Error var role model.Role
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { result := tx.Where("code = ?", definition.Code).Limit(1).Find(&role)
return err if result.Error != nil {
} return result.Error
if opsAdmin.ID > 0 { }
return s.appendRolePermissionsByCode(tx, &opsAdmin, defaultRolePermissionMigrationCodes(opsAdmin.Code)) if result.RowsAffected == 0 {
role = definition
if err := tx.Create(&role).Error; err != nil {
return err
}
} else if err := tx.Model(&role).Updates(map[string]any{
"name": definition.Name,
"description": definition.Description,
}).Error; err != nil {
return err
}
permissions := allPermissions
if definition.Code != roleCodePlatformAdmin {
permissions = nil
codes := defaultRolePermissionCodes(definition.Code)
if len(codes) > 0 {
if err := tx.Where("code IN ?", codes).Find(&permissions).Error; err != nil {
return err
}
}
}
// “同步权限”是恢复固定岗位模板的显式管理动作,必须删除历史跨模块授权,不能只追加缺失项。
if err := tx.Model(&role).Association("Permissions").Replace(permissions); err != nil {
return err
}
} }
return nil return nil
} }

View File

@ -84,11 +84,11 @@ func (s *Store) AutoMigrate() error {
&model.LoginLog{}, &model.LoginLog{},
&model.OperationLog{}, &model.OperationLog{},
&model.DataScope{}, &model.DataScope{},
&model.UserAppScope{},
&model.UserMoneyScope{}, &model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{}, &model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{}, &model.LegacyGooglePaidDetail{},
&model.LegacyGooglePaidSyncAttempt{}, &model.LegacyGooglePaidSyncAttempt{},
&model.FinanceApplication{},
&model.CoinSellerRechargeOrder{}, &model.CoinSellerRechargeOrder{},
&model.UserWithdrawalApplication{}, &model.UserWithdrawalApplication{},
&model.AdminJob{}, &model.AdminJob{},

View File

@ -0,0 +1,242 @@
package repository
import "hyapp-admin-server/internal/model"
const (
roleCodePlatformAdmin = "platform-admin"
roleCodeOperationsLead = "ops-admin"
roleCodeOperationsSpecialist = "operations-specialist"
roleCodeProductLead = "product-lead"
roleCodeProductSpecialist = "product-specialist"
roleCodeFinanceLead = "finance-lead"
roleCodeFinanceSpecialist = "finance-specialist"
)
// managedDefaultRoles 对应产品确认的七类固定岗位。platform-admin 与 ops-admin 保留历史 code
// 避免存量用户关联、财务全量数据范围判断以及已执行迁移中的角色引用失效;展示名称按新岗位体系收敛。
var managedDefaultRoles = []model.Role{
{Name: "超级管理员", Code: roleCodePlatformAdmin, Description: "拥有管理后台全部权限"},
{Name: "运营负责人", Code: roleCodeOperationsLead, Description: "负责运营、团队、活动和日常配置管理"},
{Name: "运营专员", Code: roleCodeOperationsSpecialist, Description: "负责日常运营执行,不含后台设置和高风险财务配置"},
{Name: "产品负责人", Code: roleCodeProductLead, Description: "负责产品配置、资源、活动和游戏管理"},
{Name: "产品专员", Code: roleCodeProductSpecialist, Description: "负责资源配置,并只读查看关联业务模块"},
{Name: "财务负责人", Code: roleCodeFinanceLead, Description: "负责财务看板、充值对账和提现审核"},
{Name: "财务专员", Code: roleCodeFinanceSpecialist, Description: "负责财务看板、充值对账和提现审核"},
}
var (
workbenchOverview = []string{"overview:view"}
workbenchOperations = []string{
"finance-order:coin-seller-recharge:view",
"finance-order:coin-seller-recharge:create",
"finance-order:coin-seller-recharge:verify",
"finance-order:coin-seller-recharge:grant",
"finance-withdrawal:view",
"finance-withdrawal:audit",
}
workbenchFinance = []string{
"finance:view",
"payment-bill:view",
"payment-bill:export",
"finance-withdrawal:view",
"finance-withdrawal:audit",
"room-rps-order:view",
}
appUserRead = []string{
"app-user:view", "app-user:export",
"level-config:view", "pretty-id:view", "risk-config:view", "region-block:view",
}
appUserOperationsManage = []string{
"app-user:update", "app-user:level", "app-user:status", "app-user:password",
"level-config:update", "pretty-id:update", "pretty-id:grant", "risk-config:update", "region-block:update",
}
appUserProductManage = []string{
"level-config:update", "pretty-id:update", "pretty-id:grant", "risk-config:update", "region-block:update",
}
teamRead = []string{
"bd:view", "agency:view", "host:view",
"host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view",
"coin-seller:view", "coin-seller-sub-application:view", "host-withdrawal:view", "point-withdrawal-config:view",
}
teamStructureManage = []string{
"bd:create", "bd:update", "agency:create", "agency:status", "agency:delete",
}
teamLeadManage = []string{
"host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish",
"team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete",
"host-salary-settlement:settle",
"coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
"coin-seller-sub-application:audit", "point-withdrawal-config:update",
}
roomRead = []string{
"room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view",
}
roomManage = []string{
"room:update", "room:delete", "room-pin:create", "room-pin:cancel",
"room-config:update", "room-whitelist:update", "room-robot:create", "room-robot:update",
}
appConfigRead = []string{
"app-config:view", "app-version:view", "full-server-notice:view",
}
appConfigManage = []string{
"app-config:update", "full-server-notice:send",
}
appVersionManage = []string{
"app-version:create", "app-version:update", "app-version:delete",
}
resourceRead = []string{
"resource:view", "resource-shop:view", "resource-group:view",
"gift:view", "resource-grant:view", "emoji-pack:view",
}
resourceManage = []string{
"resource:create", "resource:update", "resource:delete", "upload:create",
"resource-shop:update", "resource-group:create", "resource-group:update",
"gift:create", "gift:update", "gift:status", "gift:delete",
"resource-grant:create", "resource-grant:revoke", "emoji-pack:create",
}
operationsRead = []string{
"gift-record:view", "coin-ledger:view", "coin-seller-ledger:view", "report:view",
}
operationsProductManage = []string{
"gift-diamond:view", "gift-diamond:update",
}
operationsLeadManage = []string{
"coin-adjustment:view", "coin-adjustment:create",
"gift-diamond:view", "gift-diamond:update",
"policy-template:view", "policy-template:create",
"policy-instance:create", "policy-instance:update", "policy-instance:publish",
}
paymentRead = []string{
"payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view",
}
paymentBillExport = []string{"payment-bill:export"}
paymentBillRefresh = []string{"payment-bill:refresh"}
paymentOperationsManage = []string{
"payment-third-party:update-method", "payment-third-party:sync-methods",
"payment-product:create", "payment-product:update", "payment-product:delete",
}
activityRead = []string{
"daily-task:view", "first-recharge-reward:view", "cumulative-recharge-reward:view",
"invite-activity-reward:view", "seven-day-checkin:view", "room-rocket:view", "room-turnover-reward:view",
"wheel:view", "weekly-star:view", "agency-opening:view", "user-leaderboard:view", "red-packet:view",
"cp-config:view", "cp-weekly-rank:view", "vip-config:view", "achievement:view",
}
activityManage = []string{
"daily-task:create", "daily-task:update", "daily-task:status",
"first-recharge-reward:update", "cumulative-recharge-reward:update", "invite-activity-reward:update",
"seven-day-checkin:update", "room-rocket:update", "room-turnover-reward:update", "room-turnover-reward:retry",
"wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:create", "agency-opening:update", "red-packet:update",
"cp-config:update", "cp-weekly-rank:update", "vip-config:update",
"achievement:create", "achievement:update",
}
gameRead = []string{
"game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view",
}
gameManage = []string{
"game-catalog:platform", "game-catalog:create", "game-catalog:update", "game-catalog:status", "game-catalog:delete",
"self-game:update",
"game-robot:create", "game-robot:update", "game-robot:delete",
"room-rps-config:update", "room-rps-order:update",
}
gameOperationsSpecialistManage = []string{
"game-robot:create", "game-robot:update", "game-robot:delete", "room-rps-config:update",
}
geoRead = []string{"country:view", "region:view"}
geoManage = []string{"country:create", "country:update", "country:status", "region:create", "region:update", "region:status"}
)
// defaultRolePermissionCodes 只组合表格中明确勾选的模块。相同页面曾复用 game:* 或
// payment-third-party:update 的地方已经拆成页面级权限,避免为了一个按钮把相邻模块一起授权。
func defaultRolePermissionCodes(code string) []string {
switch code {
case roleCodeOperationsLead:
return mergePermissionCodes(
workbenchOverview, workbenchOperations,
appUserRead, appUserOperationsManage,
teamRead, teamStructureManage, teamLeadManage,
roomRead, roomManage,
[]string{"app-config:view"}, appConfigManage,
resourceRead, resourceManage,
operationsRead, operationsLeadManage,
paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage,
activityRead, activityManage,
gameRead, gameManage,
geoRead, geoManage,
)
case roleCodeOperationsSpecialist:
return mergePermissionCodes(
workbenchOverview, workbenchOperations,
appUserRead, appUserOperationsManage,
teamRead, teamStructureManage,
roomRead, roomManage,
[]string{"app-config:view"}, appConfigManage,
resourceRead, resourceManage,
operationsRead,
paymentRead, paymentBillExport, paymentBillRefresh,
activityRead,
gameRead, gameOperationsSpecialistManage,
geoRead,
)
case roleCodeProductLead:
return mergePermissionCodes(
workbenchOverview,
appUserRead, appUserProductManage,
teamRead,
roomRead, roomManage,
appConfigRead, appConfigManage, appVersionManage,
resourceRead, resourceManage,
operationsRead, operationsProductManage,
paymentRead,
activityRead, activityManage,
gameRead, gameManage,
geoRead,
)
case roleCodeProductSpecialist:
return mergePermissionCodes(
workbenchOverview,
appUserRead,
roomRead, []string{"room-robot:create", "room-robot:update"},
appConfigRead,
resourceRead, resourceManage,
operationsRead,
paymentRead,
activityRead,
gameRead,
geoRead,
)
case roleCodeFinanceLead, roleCodeFinanceSpecialist:
return mergePermissionCodes(workbenchOverview, workbenchFinance)
default:
return nil
}
}
func mergePermissionCodes(groups ...[]string) []string {
total := 0
for _, group := range groups {
total += len(group)
}
out := make([]string, 0, total)
seen := make(map[string]struct{}, total)
for _, group := range groups {
for _, code := range group {
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
out = append(out, code)
}
}
return out
}

View File

@ -110,7 +110,10 @@ var defaultPermissions = []model.Permission{
{Name: "子币商申请审核", Code: "coin-seller-sub-application:audit", Kind: "button"}, {Name: "子币商申请审核", Code: "coin-seller-sub-application:audit", Kind: "button"},
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"}, {Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"}, {Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
{Name: "送礼记录查看", Code: "gift-record:view", Kind: "menu"},
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"}, {Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
{Name: "币商提现白名单查看", Code: "point-withdrawal-config:view", Kind: "menu"},
{Name: "币商提现白名单编辑", Code: "point-withdrawal-config:update", Kind: "button"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"}, {Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"}, {Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
{Name: "举报列表查看", Code: "report:view", Kind: "menu"}, {Name: "举报列表查看", Code: "report:view", Kind: "menu"},
@ -124,34 +127,48 @@ var defaultPermissions = []model.Permission{
{Name: "系统消息推送查看", Code: "full-server-notice:view", Kind: "menu"}, {Name: "系统消息推送查看", Code: "full-server-notice:view", Kind: "menu"},
{Name: "系统消息推送发送", Code: "full-server-notice:send", Kind: "button"}, {Name: "系统消息推送发送", Code: "full-server-notice:send", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"}, {Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "支付账单导出", Code: "payment-bill:export", Kind: "button"},
{Name: "支付账单刷新", Code: "payment-bill:refresh", Kind: "button"},
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"}, {Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"}, {Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button", Description: "兼容旧角色的三方支付写权限"},
{Name: "三方支付方式更新", Code: "payment-third-party:update-method", Kind: "button"},
{Name: "三方支付方式同步", Code: "payment-third-party:sync-methods", Kind: "button"},
{Name: "三方支付汇率编辑", Code: "payment-third-party:update-rate", Kind: "button"},
{Name: "三方支付汇率同步", Code: "payment-third-party:sync-rates", Kind: "button"},
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"}, {Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
{Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"}, {Name: "三方临时支付链接创建", Code: "payment-temporary-link:create", Kind: "button"},
// 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit是否能看到审核列表由 audit 权限单独决定。
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"}, {Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
{Name: "财务申请发起", Code: "finance-application:create", Kind: "button"},
{Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"},
{Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"}, {Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"},
{Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"}, {Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"},
{Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"}, {Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"},
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"}, {Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"}, {Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"}, {Name: "用户提现申请审核", Code: "finance-withdrawal:audit", Kind: "button"},
{Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"},
{Name: "财务增加用户钱包", Code: "finance-operation:user-wallet-credit", Kind: "button"},
{Name: "财务扣减用户钱包", Code: "finance-operation:user-wallet-debit", Kind: "button"},
{Name: "财务增加币商金币", Code: "finance-operation:coin-seller-coin-credit", Kind: "button"},
{Name: "财务减少币商金币", Code: "finance-operation:coin-seller-coin-debit", Kind: "button"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"}, {Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"}, {Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"}, {Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
{Name: "内购配置删除", Code: "payment-product:delete", Kind: "button"}, {Name: "内购配置删除", Code: "payment-product:delete", Kind: "button"},
{Name: "游戏查看", Code: "game:view", Kind: "menu"}, {Name: "游戏查看", Code: "game:view", Kind: "menu", Description: "兼容旧角色的游戏管理查看权限"},
{Name: "游戏创建", Code: "game:create", Kind: "button"}, {Name: "游戏创建", Code: "game:create", Kind: "button", Description: "兼容旧角色的游戏管理创建权限"},
{Name: "游戏更新", Code: "game:update", Kind: "button"}, {Name: "游戏更新", Code: "game:update", Kind: "button", Description: "兼容旧角色的游戏管理更新权限"},
{Name: "游戏状态", Code: "game:status", Kind: "button"}, {Name: "游戏状态", Code: "game:status", Kind: "button", Description: "兼容旧角色的游戏管理状态权限"},
{Name: "游戏删除", Code: "game:delete", Kind: "button"}, {Name: "游戏删除", Code: "game:delete", Kind: "button", Description: "兼容旧角色的游戏管理删除权限"},
{Name: "游戏列表查看", Code: "game-catalog:view", Kind: "menu"},
{Name: "游戏平台配置", Code: "game-catalog:platform", Kind: "button"},
{Name: "游戏列表创建", Code: "game-catalog:create", Kind: "button"},
{Name: "游戏列表更新", Code: "game-catalog:update", Kind: "button"},
{Name: "游戏列表状态", Code: "game-catalog:status", Kind: "button"},
{Name: "游戏列表删除", Code: "game-catalog:delete", Kind: "button"},
{Name: "自研游戏查看", Code: "self-game:view", Kind: "menu"},
{Name: "自研游戏配置", Code: "self-game:update", Kind: "button"},
{Name: "全站机器人查看", Code: "game-robot:view", Kind: "menu"},
{Name: "全站机器人创建", Code: "game-robot:create", Kind: "button"},
{Name: "全站机器人更新", Code: "game-robot:update", Kind: "button"},
{Name: "全站机器人删除", Code: "game-robot:delete", Kind: "button"},
{Name: "房内猜拳配置查看", Code: "room-rps-config:view", Kind: "menu"},
{Name: "房内猜拳配置更新", Code: "room-rps-config:update", Kind: "button"},
{Name: "房内猜拳订单查看", Code: "room-rps-order:view", Kind: "menu"},
{Name: "房内猜拳订单更新", Code: "room-rps-order:update", Kind: "button"},
{Name: "每日任务查看", Code: "daily-task:view", Kind: "menu"}, {Name: "每日任务查看", Code: "daily-task:view", Kind: "menu"},
{Name: "每日任务创建", Code: "daily-task:create", Kind: "button"}, {Name: "每日任务创建", Code: "daily-task:create", Kind: "button"},
{Name: "每日任务更新", Code: "daily-task:update", Kind: "button"}, {Name: "每日任务更新", Code: "daily-task:update", Kind: "button"},
@ -161,6 +178,12 @@ var defaultPermissions = []model.Permission{
{Name: "成就配置更新", Code: "achievement:update", Kind: "button"}, {Name: "成就配置更新", Code: "achievement:update", Kind: "button"},
{Name: "注册奖励查看", Code: "registration-reward:view", Kind: "menu"}, {Name: "注册奖励查看", Code: "registration-reward:view", Kind: "menu"},
{Name: "注册奖励更新", Code: "registration-reward:update", Kind: "button"}, {Name: "注册奖励更新", Code: "registration-reward:update", Kind: "button"},
{Name: "首充礼包查看", Code: "first-recharge-reward:view", Kind: "menu"},
{Name: "首充礼包更新", Code: "first-recharge-reward:update", Kind: "button"},
{Name: "累充奖励查看", Code: "cumulative-recharge-reward:view", Kind: "menu"},
{Name: "累充奖励更新", Code: "cumulative-recharge-reward:update", Kind: "button"},
{Name: "邀请活动查看", Code: "invite-activity-reward:view", Kind: "menu"},
{Name: "邀请活动更新", Code: "invite-activity-reward:update", Kind: "button"},
{Name: "七日签到查看", Code: "seven-day-checkin:view", Kind: "menu"}, {Name: "七日签到查看", Code: "seven-day-checkin:view", Kind: "menu"},
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"}, {Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
@ -169,6 +192,9 @@ var defaultPermissions = []model.Permission{
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"}, {Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"}, {Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},
{Name: "房间火箭更新", Code: "room-rocket:update", Kind: "button"}, {Name: "房间火箭更新", Code: "room-rocket:update", Kind: "button"},
{Name: "房间流水奖励查看", Code: "room-turnover-reward:view", Kind: "menu"},
{Name: "房间流水奖励更新", Code: "room-turnover-reward:update", Kind: "button"},
{Name: "房间流水奖励重试", Code: "room-turnover-reward:retry", Kind: "button"},
{Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"}, {Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"},
{Name: "红包配置查看", Code: "red-packet:view", Kind: "menu"}, {Name: "红包配置查看", Code: "red-packet:view", Kind: "menu"},
{Name: "红包配置更新", Code: "red-packet:update", Kind: "button"}, {Name: "红包配置更新", Code: "red-packet:update", Kind: "button"},
@ -322,6 +348,7 @@ func (s *Store) seedMenus() error {
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 70, Visible: true}, {ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 70, Visible: true},
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 71, Visible: true}, {ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 71, Visible: true},
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 72, Visible: true}, {ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 72, Visible: true},
{ParentID: &operationsID, Title: "送礼记录", Code: "operation-gift-records", Path: "/operations/gift-records", Icon: "gift", PermissionCode: "gift-record:view", Sort: 67, Visible: true},
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true}, {ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
{ParentID: &operationsID, Title: "币商流水", Code: "operation-coin-seller-ledger", Path: "/operations/coin-seller-ledger", Icon: "receipt", PermissionCode: "coin-seller-ledger:view", Sort: 69, Visible: true}, {ParentID: &operationsID, Title: "币商流水", Code: "operation-coin-seller-ledger", Path: "/operations/coin-seller-ledger", Icon: "receipt", PermissionCode: "coin-seller-ledger:view", Sort: 69, Visible: true},
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 70, Visible: true}, {ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 70, Visible: true},
@ -347,9 +374,11 @@ func (s *Store) seedMenus() error {
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 79, Visible: true}, {ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 79, Visible: true},
{ParentID: &activityID, Title: "代理开业活动", Code: "agency-opening", Path: "/activities/agency-opening", Icon: "workspace_premium", PermissionCode: "agency-opening:view", Sort: 80, Visible: true}, {ParentID: &activityID, Title: "代理开业活动", Code: "agency-opening", Path: "/activities/agency-opening", Icon: "workspace_premium", PermissionCode: "agency-opening:view", Sort: 80, Visible: true},
{ParentID: &activityID, Title: "转盘抽奖", Code: "wheel", Path: "/activities/wheel", Icon: "casino", PermissionCode: "wheel:view", Sort: 81, Visible: true}, {ParentID: &activityID, Title: "转盘抽奖", Code: "wheel", Path: "/activities/wheel", Icon: "casino", PermissionCode: "wheel:view", Sort: 81, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true}, {ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game-catalog:view", Sort: 70, Visible: true},
{ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "game:view", Sort: 80, Visible: true}, {ParentID: &gameID, Title: "自研游戏", Code: "self-games", Path: "/games/self-games", Icon: "settings", PermissionCode: "self-game:view", Sort: 80, Visible: true},
{ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true}, {ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game-robot:view", Sort: 90, Visible: true},
{ParentID: &gameID, Title: "房内猜拳配置", Code: "room-rps-config", Path: "/games/room-rps/config", Icon: "settings", PermissionCode: "room-rps-config:view", Sort: 100, Visible: true},
{ParentID: &gameID, Title: "房内猜拳订单", Code: "room-rps-challenges", Path: "/games/room-rps/challenges", Icon: "history", PermissionCode: "room-rps-order:view", Sort: 110, 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-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: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
{ParentID: &hostOrgID, Title: "经理列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "bd:view", Sort: 80, Visible: true}, {ParentID: &hostOrgID, Title: "经理列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "bd:view", Sort: 80, Visible: true},
@ -446,31 +475,34 @@ func (s *Store) seedRoles() error {
return err return err
} }
roles := []model.Role{ for _, definition := range managedDefaultRoles {
{Name: "平台管理员", Code: "platform-admin", Description: "拥有管理后台全部权限"}, role := model.Role{}
{Name: "运维管理员", Code: "ops-admin", Description: "管理用户状态和后台操作"}, result := s.db.Where("code = ?", definition.Code).Limit(1).Find(&role)
{Name: "审计员", Code: "auditor", Description: "查看日志和审计记录"}, if result.Error != nil {
{Name: "只读用户", Code: "readonly", Description: "只读查看后台数据"}, return result.Error
} }
for _, role := range roles { if result.RowsAffected == 0 {
if err := s.db.Where("code = ?", role.Code).FirstOrCreate(&role).Error; err != nil { role = definition
if err := s.db.Create(&role).Error; err != nil {
return err
}
} else if err := s.db.Model(&role).Updates(map[string]any{
"name": definition.Name,
"description": definition.Description,
}).Error; err != nil {
return err return err
} }
if role.Code == "platform-admin" {
if role.Code == roleCodePlatformAdmin {
if err := s.db.Model(&role).Association("Permissions").Replace(permissions); err != nil { if err := s.db.Model(&role).Association("Permissions").Replace(permissions); err != nil {
return err return err
} }
continue continue
} }
// 预置角色只在没有任何权限时写入默认模板,避免后续人工调整被启动种子覆盖。 // 显式 bootstrap 的职责是恢复产品确认的岗位模板;普通启动不会执行 Seed
if s.db.Model(&role).Association("Permissions").Count() == 0 { // 因此这里可以精确替换固定岗位权限,同时不影响用户自行创建的其他角色。
if err := s.replaceRolePermissionsByCode(&role, defaultRolePermissionCodes(role.Code)); err != nil { if err := s.replaceRolePermissionsByCode(&role, defaultRolePermissionCodes(role.Code)); err != nil {
return err
}
continue
}
if err := s.appendRolePermissionsByCode(s.db, &role, defaultRolePermissionMigrationCodes(role.Code)); err != nil {
return err return err
} }
} }
@ -495,7 +527,7 @@ func (s *Store) seedUsers(cfg config.Config) error {
} }
var role model.Role var role model.Role
if err := s.db.Where("code = ?", "platform-admin").First(&role).Error; err != nil { if err := s.db.Where("code = ?", roleCodePlatformAdmin).First(&role).Error; err != nil {
return err return err
} }
@ -507,6 +539,8 @@ func (s *Store) seedUsers(cfg config.Config) error {
Team: "运营团队", Team: "运营团队",
TeamID: &operationsTeamID, TeamID: &operationsTeamID,
Status: model.UserStatusActive, Status: model.UserStatusActive,
// 全新环境的唯一 bootstrap 管理员必须能进入 App 主站,后续仍可在用户编辑中改成 selected/none。
AppScopeMode: model.UserAppScopeModeAll,
MFAEnabled: false, MFAEnabled: false,
} }
if err := s.db.Create(&user).Error; err != nil { if err := s.db.Create(&user).Error; err != nil {
@ -599,197 +633,3 @@ func (s *Store) replaceRolePermissionsByCode(role *model.Role, codes []string) e
} }
return s.db.Model(role).Association("Permissions").Replace(permissions) return s.db.Model(role).Association("Permissions").Replace(permissions)
} }
func (s *Store) appendRolePermissionsByCode(tx *gorm.DB, role *model.Role, codes []string) error {
if len(codes) == 0 {
return nil
}
var current model.Role
if err := tx.Preload("Permissions").First(&current, role.ID).Error; err != nil {
return err
}
existing := make(map[string]struct{}, len(current.Permissions))
for _, permission := range current.Permissions {
existing[permission.Code] = struct{}{}
}
missingCodes := make([]string, 0, len(codes))
for _, code := range codes {
if _, ok := existing[code]; !ok {
missingCodes = append(missingCodes, code)
}
}
if len(missingCodes) == 0 {
return nil
}
var permissions []model.Permission
if err := tx.Where("code IN ?", missingCodes).Find(&permissions).Error; err != nil {
return err
}
if len(permissions) == 0 {
return nil
}
return tx.Model(role).Association("Permissions").Append(permissions)
}
func defaultRolePermissionCodes(code string) []string {
switch code {
case "ops-admin":
return []string{
"overview:view",
"user:view", "user:status",
"team:view", "team:create", "team:update",
"app-user:view", "app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password", "app-user:token",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"risk-config:view", "risk-config:update",
"region-block:view", "region-block:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-whitelist:view", "room-whitelist:update", "room-robot:view", "room-robot:create", "room-robot: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:delete",
"resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create", "resource-grant:revoke",
"gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
"emoji-pack:view", "emoji-pack:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host:view", "host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update",
"seven-day-checkin:view", "seven-day-checkin:update",
"room-rocket:view", "room-rocket:update",
"red-packet:view", "red-packet:update",
"cp-config:view", "cp-config:update", "cp-weekly-rank:view", "cp-weekly-rank:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:view", "agency-opening:create", "agency-opening:update",
"log:view",
"job:view", "job:cancel", "export:create",
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller-sub-application:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"team:view",
"app-user:view",
"level-config:view",
"pretty-id:view",
"risk-config:view",
"region-block:view",
"room:view",
"room-pin:view",
"room-config:view",
"room-whitelist:view",
"room-robot:view",
"app-config:view",
"app-version:view",
"resource:view",
"resource-shop:view",
"resource-group:view",
"resource-grant:view",
"gift:view",
"emoji-pack:view",
"country:view",
"region:view",
"host:view",
"host-agency-policy:view",
"team-salary-policy:view",
"host-salary-settlement:view",
"host-withdrawal:view",
"agency:view",
"bd:view",
"coin-seller:view",
"coin-seller-sub-application:view",
"coin-ledger:view",
"coin-seller-ledger:view",
"coin-adjustment:view",
"report:view",
"gift-diamond:view",
"policy-template:view",
"full-server-notice:view",
"lucky-gift:view",
"wheel:view",
"payment-bill:view",
"payment-third-party:view",
"payment-temporary-link:view",
"finance-order:coin-seller-recharge:view",
"payment-product:view",
"game:view",
"daily-task:view",
"achievement:view",
"seven-day-checkin:view",
"room-rocket:view",
"red-packet:view",
"cp-config:view",
"cp-weekly-rank:view",
"vip-config:view",
"weekly-star:view",
"agency-opening:view",
"role:view",
"permission:view",
"menu:view",
"log:view",
"job:view",
}
default:
return nil
}
}
func defaultRolePermissionMigrationCodes(code string) []string {
switch code {
case "ops-admin":
return []string{
"team:view", "team:create", "team:update",
"app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password", "app-user:token",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-whitelist:view", "room-whitelist:update", "room-robot:view", "room-robot:create", "room-robot:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"risk-config:view", "risk-config:update",
"region-block:view", "region-block:update",
"resource:view", "resource:create", "resource:update", "resource:delete",
"resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create", "resource-grant:revoke",
"gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
"emoji-pack:view", "emoji-pack:create",
"upload:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate", "coin-seller-sub-application:view", "coin-seller-sub-application:audit",
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "policy-template:view", "policy-template:create", "policy-instance:create", "policy-instance:update", "policy-instance:publish", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update",
"seven-day-checkin:view", "seven-day-checkin:update",
"room-rocket:view", "room-rocket:update",
"red-packet:view", "red-packet:update",
"cp-config:view", "cp-config:update", "cp-weekly-rank:view", "cp-weekly-rank:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:view", "agency-opening:create", "agency-opening:update",
}
case "auditor", "readonly":
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-seller-sub-application:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "policy-template:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
default:
return nil
}
}

View File

@ -1,6 +1,13 @@
package repository package repository
import "testing" import (
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) { func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
permissions := map[string]struct{}{} permissions := map[string]struct{}{}
@ -17,51 +24,228 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
for _, code := range []string{ for _, code := range []string{
"payment-temporary-link:create", "payment-temporary-link:create",
"finance:view", "finance:view",
"finance-application:create",
"finance-application:audit",
"finance-withdrawal:view", "finance-withdrawal:view",
"finance-operation:user-coin-credit",
"finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
"host-withdrawal:view", "host-withdrawal:view",
} { } {
if _, ok := permissions[code]; !ok { if _, ok := permissions[code]; !ok {
t.Fatalf("finance permission %s missing from default seed", code) t.Fatalf("finance permission %s missing from default seed", code)
} }
} }
}
func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
opsPermissions := seedTestPermissionSet(defaultRolePermissionCodes("ops-admin"))
opsMigrationPermissions := seedTestPermissionSet(defaultRolePermissionMigrationCodes("ops-admin"))
for _, code := range []string{ for _, code := range []string{
"payment-temporary-link:create",
"finance-application:create", "finance-application:create",
"finance-application:audit",
"finance-operation:user-coin-credit", "finance-operation:user-coin-credit",
"finance-operation:user-coin-debit", "finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit", "finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit", "finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit", "finance-operation:coin-seller-coin-debit",
} {
if _, ok := permissions[code]; ok {
t.Fatalf("removed finance application permission %s must not be seeded", code)
}
}
}
func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
opsPermissions := seedTestPermissionSet(defaultRolePermissionCodes(roleCodeOperationsLead))
for _, code := range []string{
"payment-bill:export",
"host-withdrawal:view", "host-withdrawal:view",
} { } {
if _, ok := opsPermissions[code]; !ok { if _, ok := opsPermissions[code]; !ok {
t.Fatalf("ops-admin default permissions missing %s", code) t.Fatalf("ops-admin default permissions missing %s", code)
} }
if _, ok := opsMigrationPermissions[code]; !ok {
t.Fatalf("ops-admin migration permissions missing %s", code)
}
} }
if _, ok := opsPermissions["finance-application:audit"]; ok { for _, code := range []string{
t.Fatal("ops-admin must not receive finance audit permission by default") "finance:view",
"payment-temporary-link:create",
"payment-third-party:update-rate",
"payment-third-party:sync-rates",
"finance-application:create",
"finance-application:audit",
"finance-operation:user-coin-credit",
"finance-operation:user-coin-debit",
"finance-operation:user-wallet-credit",
"finance-operation:user-wallet-debit",
"finance-operation:coin-seller-coin-credit",
"finance-operation:coin-seller-coin-debit",
} {
if _, ok := opsPermissions[code]; ok {
t.Fatalf("ops-admin must not receive removed permission %s", code)
}
} }
if _, ok := opsMigrationPermissions["finance-application:audit"]; ok { }
t.Fatal("ops-admin migration must not append finance audit permission by default")
func TestManagedDefaultRolePermissionMatrix(t *testing.T) {
if len(managedDefaultRoles) != 7 {
t.Fatalf("managed role count = %d, want 7", len(managedDefaultRoles))
}
roleNames := map[string]string{}
for _, role := range managedDefaultRoles {
if previous, ok := roleNames[role.Code]; ok {
t.Fatalf("duplicate role code %s for %s and %s", role.Code, previous, role.Name)
}
roleNames[role.Code] = role.Name
}
wantNames := map[string]string{
roleCodePlatformAdmin: "超级管理员",
roleCodeOperationsLead: "运营负责人",
roleCodeOperationsSpecialist: "运营专员",
roleCodeProductLead: "产品负责人",
roleCodeProductSpecialist: "产品专员",
roleCodeFinanceLead: "财务负责人",
roleCodeFinanceSpecialist: "财务专员",
}
if !reflect.DeepEqual(roleNames, wantNames) {
t.Fatalf("managed roles = %#v, want %#v", roleNames, wantNames)
}
defined := map[string]struct{}{}
for _, permission := range defaultPermissions {
defined[permission.Code] = struct{}{}
}
for _, role := range managedDefaultRoles {
if role.Code == roleCodePlatformAdmin {
continue
}
seen := map[string]struct{}{}
for _, code := range defaultRolePermissionCodes(role.Code) {
if _, ok := seen[code]; ok {
t.Fatalf("role %s contains duplicate permission %s", role.Code, code)
}
seen[code] = struct{}{}
if _, ok := defined[code]; !ok {
t.Fatalf("role %s references permission %s missing from defaultPermissions", role.Code, code)
}
}
}
}
func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
assertRolePermissions(t, roleCodeOperationsLead,
[]string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update"},
[]string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view"},
)
assertRolePermissions(t, roleCodeOperationsSpecialist,
[]string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export"},
[]string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create"},
)
assertRolePermissions(t, roleCodeProductLead,
[]string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"},
[]string{"bd:create", "coin-adjustment:create", "country:update", "finance:view"},
)
assertRolePermissions(t, roleCodeProductSpecialist,
[]string{"resource:update", "room-robot:update", "game-catalog:view", "daily-task:view"},
[]string{"app-config:update", "room:update", "game-catalog:update", "daily-task:update", "bd:view"},
)
financeLead := defaultRolePermissionCodes(roleCodeFinanceLead)
financeSpecialist := defaultRolePermissionCodes(roleCodeFinanceSpecialist)
if !reflect.DeepEqual(financeLead, financeSpecialist) {
t.Fatalf("finance lead and specialist permissions differ: lead=%v specialist=%v", financeLead, financeSpecialist)
}
assertRolePermissions(t, roleCodeFinanceLead,
[]string{"finance:view", "payment-bill:view", "payment-bill:export", "finance-withdrawal:audit", "room-rps-order:view"},
[]string{"game-catalog:view", "room-rps-config:view", "app-user:view", "daily-task:view"},
)
}
func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql")
if err != nil {
t.Fatalf("read role permission migration: %v", err)
}
sqlText := string(content)
for _, roleCode := range []string{
roleCodeOperationsLead,
roleCodeOperationsSpecialist,
roleCodeProductLead,
roleCodeProductSpecialist,
} {
pattern := regexp.MustCompile(`(?s)WHERE role\.code = '` + regexp.QuoteMeta(roleCode) + `'\s+AND permission\.code IN \((.*?)\);`)
matches := pattern.FindStringSubmatch(sqlText)
if len(matches) != 2 {
t.Fatalf("migration permission block missing for %s", roleCode)
}
assertMigrationPermissionCodes(t, roleCode, matches[1])
}
financePattern := regexp.MustCompile(`(?s)WHERE role\.code IN \('finance-lead', 'finance-specialist'\)\s+AND permission\.code IN \((.*?)\);`)
financeMatches := financePattern.FindStringSubmatch(sqlText)
if len(financeMatches) != 2 {
t.Fatal("migration permission block missing for finance roles")
}
assertMigrationPermissionCodes(t, roleCodeFinanceLead, financeMatches[1])
assertMigrationPermissionCodes(t, roleCodeFinanceSpecialist, financeMatches[1])
}
func TestGiftRecordPermissionMigrationExtendsOperationsReadRoles(t *testing.T) {
content, err := os.ReadFile("../../migrations/095_gift_record_navigation.sql")
if err != nil {
t.Fatalf("read gift record permission migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'gift-record:view'",
"'ops-admin'",
"'operations-specialist'",
"'product-lead'",
"'product-specialist'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("gift record migration missing %s", token)
}
}
}
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
matches := quotedCode.FindAllStringSubmatch(sqlList, -1)
actual := make([]string, 0, len(matches))
for _, match := range matches {
actual = append(actual, match[1])
}
expected := append([]string(nil), defaultRolePermissionCodes(roleCode)...)
// 093 已在生产执行不能为了新增页面回写旧迁移095 以增量方式给同一批运营只读岗位补 gift-record:view。
// 这里仍校验 093 的原始精确矩阵,新权限由上面的 095 专项测试锁定。
expected = stringSetDifference(expected, []string{"gift-record:view"})
sort.Strings(actual)
sort.Strings(expected)
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("migration permissions for %s differ\nactual-only=%v\nexpected-only=%v", roleCode, stringSetDifference(actual, expected), stringSetDifference(expected, actual))
}
}
func stringSetDifference(left []string, right []string) []string {
rightSet := make(map[string]struct{}, len(right))
for _, item := range right {
rightSet[item] = struct{}{}
}
out := make([]string, 0)
for _, item := range left {
if _, ok := rightSet[item]; !ok {
out = append(out, item)
}
}
return out
}
func assertRolePermissions(t *testing.T, roleCode string, required []string, forbidden []string) {
t.Helper()
permissions := seedTestPermissionSet(defaultRolePermissionCodes(roleCode))
for _, code := range required {
if _, ok := permissions[code]; !ok {
t.Errorf("role %s missing required permission %s", roleCode, code)
}
}
for _, code := range forbidden {
if _, ok := permissions[code]; ok {
t.Errorf("role %s must not have permission %s", roleCode, code)
}
} }
} }

View File

@ -19,13 +19,13 @@ import (
"hyapp-admin-server/internal/modules/dailytask" "hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard" "hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/databi" "hyapp-admin-server/internal/modules/databi"
"hyapp-admin-server/internal/modules/financeapplication"
"hyapp-admin-server/internal/modules/financeorder" "hyapp-admin-server/internal/modules/financeorder"
"hyapp-admin-server/internal/modules/financewithdrawal" "hyapp-admin-server/internal/modules/financewithdrawal"
"hyapp-admin-server/internal/modules/firstrechargereward" "hyapp-admin-server/internal/modules/firstrechargereward"
"hyapp-admin-server/internal/modules/fullservernotice" "hyapp-admin-server/internal/modules/fullservernotice"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement" gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
"hyapp-admin-server/internal/modules/giftdiamond" "hyapp-admin-server/internal/modules/giftdiamond"
"hyapp-admin-server/internal/modules/giftrecord"
"hyapp-admin-server/internal/modules/health" "hyapp-admin-server/internal/modules/health"
"hyapp-admin-server/internal/modules/hostagencypolicy" "hyapp-admin-server/internal/modules/hostagencypolicy"
"hyapp-admin-server/internal/modules/hostorg" "hyapp-admin-server/internal/modules/hostorg"
@ -61,6 +61,7 @@ import (
"hyapp-admin-server/internal/modules/weeklystar" "hyapp-admin-server/internal/modules/weeklystar"
"hyapp-admin-server/internal/modules/wheel" "hyapp-admin-server/internal/modules/wheel"
"hyapp-admin-server/internal/platform/logging" "hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/service" "hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -84,12 +85,12 @@ type Handlers struct {
Dashboard *dashboard.Handler Dashboard *dashboard.Handler
Databi *databi.Handler Databi *databi.Handler
FirstRechargeReward *firstrechargereward.Handler FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceOrder *financeorder.Handler FinanceOrder *financeorder.Handler
FinanceWithdrawal *financewithdrawal.Handler FinanceWithdrawal *financewithdrawal.Handler
FullServerNotice *fullservernotice.Handler FullServerNotice *fullservernotice.Handler
Game *gamemanagement.Handler Game *gamemanagement.Handler
GiftDiamond *giftdiamond.Handler GiftDiamond *giftdiamond.Handler
GiftRecord *giftrecord.Handler
Health *health.Handler Health *health.Handler
HostAgencyPolicy *hostagencypolicy.Handler HostAgencyPolicy *hostagencypolicy.Handler
HostOrg *hostorg.Handler HostOrg *hostorg.Handler
@ -126,7 +127,7 @@ type Handlers struct {
Wheel *wheel.Handler Wheel *wheel.Handler
} }
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { func New(cfg config.Config, auth *service.AuthService, store *repository.Store, h Handlers) *gin.Engine {
engine := gin.New() engine := gin.New()
engine.Use(middleware.RequestID(), middleware.AppCode(), logging.GinAccessLogger(), gin.Recovery(), middleware.CORS(cfg)) engine.Use(middleware.RequestID(), middleware.AppCode(), logging.GinAccessLogger(), gin.Recovery(), middleware.CORS(cfg))
@ -136,63 +137,65 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
api := engine.Group("/api/v1") api := engine.Group("/api/v1")
protected := api.Group("") protected := api.Group("")
protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit)) protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit))
appProtected := protected.Group("")
appProtected.Use(middleware.RequireAppScope(store))
authroutes.RegisterRoutes(api, protected, h.Auth) authroutes.RegisterRoutes(api, protected, h.Auth)
agencyopening.RegisterRoutes(protected, h.AgencyOpening) agencyopening.RegisterRoutes(appProtected, h.AgencyOpening)
achievementconfig.RegisterRoutes(protected, h.AchievementConfig) achievementconfig.RegisterRoutes(appProtected, h.AchievementConfig)
coinledger.RegisterRoutes(protected, h.CoinLedger) coinledger.RegisterRoutes(appProtected, h.CoinLedger)
menu.RegisterRoutes(protected, h.Menu) menu.RegisterRoutes(protected, h.Menu)
rbac.RegisterRoutes(protected, h.RBAC) rbac.RegisterRoutes(protected, h.RBAC)
redpacket.RegisterRoutes(protected, h.RedPacket) redpacket.RegisterRoutes(appProtected, h.RedPacket)
reportmodule.RegisterRoutes(protected, h.Report) reportmodule.RegisterRoutes(appProtected, h.Report)
resourcemodule.RegisterRoutes(protected, h.Resource) resourcemodule.RegisterRoutes(appProtected, h.Resource)
adminuser.RegisterRoutes(protected, h.AdminUser) adminuser.RegisterRoutes(protected, h.AdminUser)
appuser.RegisterRoutes(protected, h.AppUser) appuser.RegisterRoutes(appProtected, h.AppUser)
appregistry.RegisterRoutes(protected, h.AppRegistry) appregistry.RegisterRoutes(protected, h.AppRegistry)
appconfig.RegisterRoutes(protected, h.AppConfig) appconfig.RegisterRoutes(appProtected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion) countryregion.RegisterRoutes(appProtected, h.CountryRegion)
cprelation.RegisterRoutes(protected, h.CPRelation) cprelation.RegisterRoutes(appProtected, h.CPRelation)
cpweeklyrank.RegisterRoutes(protected, h.CPWeeklyRank) cpweeklyrank.RegisterRoutes(appProtected, h.CPWeeklyRank)
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge) cumulativerechargereward.RegisterRoutes(appProtected, h.CumulativeRecharge)
dailytask.RegisterRoutes(protected, h.DailyTask) dailytask.RegisterRoutes(appProtected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward) firstrechargereward.RegisterRoutes(appProtected, h.FirstRechargeReward)
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
financeorder.RegisterRoutes(protected, h.FinanceOrder) financeorder.RegisterRoutes(protected, h.FinanceOrder)
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal) financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
fullservernotice.RegisterRoutes(protected, h.FullServerNotice) fullservernotice.RegisterRoutes(appProtected, h.FullServerNotice)
registrationreward.RegisterRoutes(protected, h.RegistrationReward) registrationreward.RegisterRoutes(appProtected, h.RegistrationReward)
regionblock.RegisterRoutes(protected, h.RegionBlock) regionblock.RegisterRoutes(appProtected, h.RegionBlock)
riskconfig.RegisterRoutes(protected, h.RiskConfig) riskconfig.RegisterRoutes(appProtected, h.RiskConfig)
gamemanagement.RegisterRoutes(protected, h.Game) gamemanagement.RegisterRoutes(appProtected, h.Game)
giftdiamond.RegisterRoutes(protected, h.GiftDiamond) giftdiamond.RegisterRoutes(appProtected, h.GiftDiamond)
roomadmin.RegisterRoutes(protected, h.RoomAdmin) giftrecord.RegisterRoutes(appProtected, h.GiftRecord)
roomrocket.RegisterRoutes(protected, h.RoomRocket) roomadmin.RegisterRoutes(appProtected, h.RoomAdmin)
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward) roomrocket.RegisterRoutes(appProtected, h.RoomRocket)
dashboard.RegisterRoutes(protected, h.Dashboard) roomturnoverreward.RegisterRoutes(appProtected, h.RoomTurnoverReward)
dashboard.RegisterRoutes(appProtected, protected, h.Dashboard)
databi.RegisterRoutes(protected, h.Databi) databi.RegisterRoutes(protected, h.Databi)
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy) hostagencypolicy.RegisterRoutes(appProtected, h.HostAgencyPolicy)
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement) hostsalarysettlement.RegisterRoutes(appProtected, h.HostSalarySettlement)
hostorg.RegisterRoutes(protected, h.HostOrg) hostorg.RegisterRoutes(appProtected, h.HostOrg)
hostwithdrawal.RegisterRoutes(protected, h.HostWithdrawal) hostwithdrawal.RegisterRoutes(appProtected, h.HostWithdrawal)
inviteactivityreward.RegisterRoutes(protected, h.InviteActivityReward) inviteactivityreward.RegisterRoutes(appProtected, h.InviteActivityReward)
audit.RegisterRoutes(protected, h.Audit) audit.RegisterRoutes(protected, h.Audit)
payment.RegisterRoutes(protected, h.Payment) payment.RegisterRoutes(protected, appProtected, h.Payment)
policyconfig.RegisterRoutes(protected, h.PolicyConfig) policyconfig.RegisterRoutes(appProtected, h.PolicyConfig)
pointwithdrawalconfig.RegisterRoutes(protected, h.PointWithdrawalConfig) pointwithdrawalconfig.RegisterRoutes(appProtected, h.PointWithdrawalConfig)
prettyid.RegisterRoutes(protected, h.PrettyID) prettyid.RegisterRoutes(appProtected, h.PrettyID)
job.RegisterRoutes(protected, h.Job) job.RegisterRoutes(protected, h.Job)
levelconfig.RegisterRoutes(protected, h.LevelConfig) levelconfig.RegisterRoutes(appProtected, h.LevelConfig)
opscenter.RegisterRoutes(protected, h.OpsCenter) opscenter.RegisterRoutes(appProtected, h.OpsCenter)
upload.RegisterRoutes(protected, h.Upload) upload.RegisterRoutes(appProtected, h.Upload)
search.RegisterRoutes(protected, h.Search) search.RegisterRoutes(protected, h.Search)
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn) sevendaycheckin.RegisterRoutes(appProtected, h.SevenDayCheckIn)
team.RegisterRoutes(protected, h.Team) team.RegisterRoutes(protected, h.Team)
teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy) teamsalarypolicy.RegisterRoutes(appProtected, h.TeamSalaryPolicy)
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement) teamsalarysettlement.RegisterRoutes(appProtected, h.TeamSalarySettlement)
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard) userleaderboard.RegisterRoutes(appProtected, h.UserLeaderboard)
vipconfig.RegisterRoutes(protected, h.VIPConfig) vipconfig.RegisterRoutes(appProtected, h.VIPConfig)
weeklystar.RegisterRoutes(protected, h.WeeklyStar) weeklystar.RegisterRoutes(appProtected, h.WeeklyStar)
wheel.RegisterRoutes(protected, h.Wheel) wheel.RegisterRoutes(appProtected, h.Wheel)
return engine return engine
} }

View File

@ -0,0 +1,39 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 旧 finance-application:audit 曾兼容用户提现审核;先迁移到专属权限,避免删除申请模块时意外收回仍在使用的提现审核能力。
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT existing.role_id, withdrawal_permission.id
FROM admin_role_permissions existing
JOIN admin_permissions legacy_permission
ON legacy_permission.id = existing.permission_id
JOIN admin_permissions withdrawal_permission
ON withdrawal_permission.code = 'finance-withdrawal:audit'
WHERE legacy_permission.code = 'finance-application:audit';
-- 权限 code 有唯一索引;这里只匹配 8 个废弃 code。角色权限表规模受角色数约束一次性清理不会扫描业务流水或申请历史表。
DELETE role_permission
FROM admin_role_permissions role_permission
JOIN admin_permissions permission
ON permission.id = role_permission.permission_id
WHERE permission.code IN (
'finance-application:create',
'finance-application:audit',
'finance-operation:user-coin-credit',
'finance-operation:user-coin-debit',
'finance-operation:user-wallet-credit',
'finance-operation:user-wallet-debit',
'finance-operation:coin-seller-coin-credit',
'finance-operation:coin-seller-coin-debit'
);
DELETE FROM admin_permissions
WHERE code IN (
'finance-application:create',
'finance-application:audit',
'finance-operation:user-coin-credit',
'finance-operation:user-coin-debit',
'finance-operation:user-wallet-credit',
'finance-operation:user-wallet-debit',
'finance-operation:coin-seller-coin-credit',
'finance-operation:coin-seller-coin-debit'
);

View File

@ -0,0 +1,247 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 七类岗位及权限矩阵来自产品确认的《身份权限预览》:固定岗位使用精确模板,不能再通过旧 ops-admin 迁移不断追加跨模块权限。
-- 性能边界:本迁移只扫描 7 个 role code 和数百条 permission coderole.code、permission.code 均为唯一索引,
-- admin_role_permissions 以 (role_id, permission_id) 为主键DELETE JOIN 和 INSERT SELECT 不扫描业务流水表。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('支付账单导出', 'payment-bill:export', 'button', '允许导出充值账单,不随列表查看权限自动授予', @now_ms, @now_ms),
('支付账单刷新', 'payment-bill:refresh', 'button', '允许刷新 Google 实付明细,不随列表查看权限自动授予', @now_ms, @now_ms),
('三方支付方式更新', 'payment-third-party:update-method', 'button', '允许调整三方支付方式状态', @now_ms, @now_ms),
('三方支付方式同步', 'payment-third-party:sync-methods', 'button', '允许同步三方支付方式', @now_ms, @now_ms),
('三方支付汇率编辑', 'payment-third-party:update-rate', 'button', '允许编辑三方支付汇率', @now_ms, @now_ms),
('三方支付汇率同步', 'payment-third-party:sync-rates', 'button', '允许全局同步三方支付汇率', @now_ms, @now_ms),
('游戏列表查看', 'game-catalog:view', 'menu', '允许查看游戏列表和游戏平台', @now_ms, @now_ms),
('游戏平台配置', 'game-catalog:platform', 'button', '允许新增、编辑和同步游戏平台', @now_ms, @now_ms),
('游戏列表创建', 'game-catalog:create', 'button', '允许新增游戏', @now_ms, @now_ms),
('游戏列表更新', 'game-catalog:update', 'button', '允许编辑游戏和游戏白名单', @now_ms, @now_ms),
('游戏列表状态', 'game-catalog:status', 'button', '允许启用或停用游戏', @now_ms, @now_ms),
('游戏列表删除', 'game-catalog:delete', 'button', '允许删除游戏', @now_ms, @now_ms),
('自研游戏查看', 'self-game:view', 'menu', '允许查看自研游戏配置', @now_ms, @now_ms),
('自研游戏配置', 'self-game:update', 'button', '允许修改骰子、新手策略和档位奖池', @now_ms, @now_ms),
('全站机器人查看', 'game-robot:view', 'menu', '允许查看全站机器人', @now_ms, @now_ms),
('全站机器人创建', 'game-robot:create', 'button', '允许批量创建全站机器人', @now_ms, @now_ms),
('全站机器人更新', 'game-robot:update', 'button', '允许修改全站机器人状态', @now_ms, @now_ms),
('全站机器人删除', 'game-robot:delete', 'button', '允许删除全站机器人', @now_ms, @now_ms),
('房内猜拳配置查看', 'room-rps-config:view', 'menu', '允许查看房内猜拳配置', @now_ms, @now_ms),
('房内猜拳配置更新', 'room-rps-config:update', 'button', '允许修改房内猜拳配置', @now_ms, @now_ms),
('房内猜拳订单查看', 'room-rps-order:view', 'menu', '允许查看房内猜拳订单', @now_ms, @now_ms),
('房内猜拳订单更新', 'room-rps-order:update', 'button', '允许重试结算或手动过期房内猜拳订单', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
-- 历史粗粒度权限保留给用户自建角色兼容,但固定岗位不再绑定这些 code。
UPDATE admin_permissions
SET description = CASE code
WHEN 'payment-third-party:update' THEN '兼容旧角色的三方支付写权限'
WHEN 'game:view' THEN '兼容旧角色的游戏管理查看权限'
WHEN 'game:create' THEN '兼容旧角色的游戏管理创建权限'
WHEN 'game:update' THEN '兼容旧角色的游戏管理更新权限'
WHEN 'game:status' THEN '兼容旧角色的游戏管理状态权限'
WHEN 'game:delete' THEN '兼容旧角色的游戏管理删除权限'
ELSE description
END,
updated_at_ms = @now_ms
WHERE code IN ('payment-third-party:update', 'game:view', 'game:create', 'game:update', 'game:status', 'game:delete');
UPDATE admin_menus
SET permission_code = CASE code
WHEN 'game-list' THEN 'game-catalog:view'
WHEN 'self-games' THEN 'self-game:view'
WHEN 'game-robots' THEN 'game-robot:view'
WHEN 'room-rps-config' THEN 'room-rps-config:view'
WHEN 'room-rps-challenges' THEN 'room-rps-order:view'
ELSE permission_code
END,
updated_at_ms = @now_ms
WHERE code IN ('game-list', 'self-games', 'game-robots', 'room-rps-config', 'room-rps-challenges');
INSERT INTO admin_roles (name, code, description, created_at_ms, updated_at_ms) VALUES
('超级管理员', 'platform-admin', '拥有管理后台全部权限', @now_ms, @now_ms),
('运营负责人', 'ops-admin', '负责运营、团队、活动和日常配置管理', @now_ms, @now_ms),
('运营专员', 'operations-specialist', '负责日常运营执行,不含后台设置和高风险财务配置', @now_ms, @now_ms),
('产品负责人', 'product-lead', '负责产品配置、资源、活动和游戏管理', @now_ms, @now_ms),
('产品专员', 'product-specialist', '负责资源配置,并只读查看关联业务模块', @now_ms, @now_ms),
('财务负责人', 'finance-lead', '负责财务看板、充值对账和提现审核', @now_ms, @now_ms),
('财务专员', 'finance-specialist', '负责财务看板、充值对账和提现审核', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
updated_at_ms = @now_ms;
-- 只清理产品管理的七类固定岗位;审计员、只读用户和用户自建角色保留原绑定,避免破坏历史兼容。
DELETE role_permission
FROM admin_role_permissions role_permission
JOIN admin_roles role ON role.id = role_permission.role_id
WHERE role.code IN (
'platform-admin', 'ops-admin', 'operations-specialist',
'product-lead', 'product-specialist', 'finance-lead', 'finance-specialist'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'platform-admin';
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'ops-admin'
AND permission.code IN (
'overview:view',
'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant',
'finance-withdrawal:view', 'finance-withdrawal:audit',
'app-user:view', 'app-user:export', 'app-user:update', 'app-user:level', 'app-user:status', 'app-user:password',
'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant',
'risk-config:view', 'risk-config:update', 'region-block:view', 'region-block:update',
'bd:view', 'bd:create', 'bd:update', 'agency:view', 'agency:create', 'agency:status', 'agency:delete', 'host:view',
'host-agency-policy:view', 'host-agency-policy:create', 'host-agency-policy:update', 'host-agency-policy:delete', 'host-agency-policy:publish',
'team-salary-policy:view', 'team-salary-policy:create', 'team-salary-policy:update', 'team-salary-policy:delete',
'host-salary-settlement:view', 'host-salary-settlement:settle',
'coin-seller:view', 'coin-seller:create', 'coin-seller:update', 'coin-seller:stock-credit', 'coin-seller:exchange-rate',
'coin-seller-sub-application:view', 'coin-seller-sub-application:audit', 'host-withdrawal:view',
'point-withdrawal-config:view', 'point-withdrawal-config:update',
'room:view', 'room:update', 'room:delete', 'room-pin:view', 'room-pin:create', 'room-pin:cancel',
'room-config:view', 'room-config:update', 'room-whitelist:view', 'room-whitelist:update',
'room-robot:view', 'room-robot:create', 'room-robot:update',
'app-config:view', 'app-config:update', 'full-server-notice:send',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'coin-adjustment:view', 'coin-adjustment:create', 'report:view',
'gift-diamond:view', 'gift-diamond:update', 'policy-template:view', 'policy-template:create',
'policy-instance:create', 'policy-instance:update', 'policy-instance:publish',
'payment-bill:view', 'payment-bill:export', 'payment-bill:refresh',
'payment-third-party:view', 'payment-third-party:update-method', 'payment-third-party:sync-methods',
'payment-temporary-link:view', 'payment-product:view', 'payment-product:create', 'payment-product:update', 'payment-product:delete',
'daily-task:view', 'daily-task:create', 'daily-task:update', 'daily-task:status',
'first-recharge-reward:view', 'first-recharge-reward:update',
'cumulative-recharge-reward:view', 'cumulative-recharge-reward:update',
'invite-activity-reward:view', 'invite-activity-reward:update',
'seven-day-checkin:view', 'seven-day-checkin:update', 'room-rocket:view', 'room-rocket:update',
'room-turnover-reward:view', 'room-turnover-reward:update', 'room-turnover-reward:retry',
'wheel:view', 'wheel:update', 'weekly-star:view', 'weekly-star:create', 'weekly-star:update', 'weekly-star:settle',
'agency-opening:view', 'agency-opening:create', 'agency-opening:update', 'user-leaderboard:view',
'red-packet:view', 'red-packet:update', 'cp-config:view', 'cp-config:update',
'cp-weekly-rank:view', 'cp-weekly-rank:update', 'vip-config:view', 'vip-config:update',
'achievement:view', 'achievement:create', 'achievement:update',
'game-catalog:view', 'game-catalog:platform', 'game-catalog:create', 'game-catalog:update', 'game-catalog:status', 'game-catalog:delete',
'self-game:view', 'self-game:update', 'game-robot:view', 'game-robot:create', 'game-robot:update', 'game-robot:delete',
'room-rps-config:view', 'room-rps-config:update', 'room-rps-order:view', 'room-rps-order:update',
'country:view', 'country:create', 'country:update', 'country:status',
'region:view', 'region:create', 'region:update', 'region:status'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'operations-specialist'
AND permission.code IN (
'overview:view',
'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant',
'finance-withdrawal:view', 'finance-withdrawal:audit',
'app-user:view', 'app-user:export', 'app-user:update', 'app-user:level', 'app-user:status', 'app-user:password',
'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant',
'risk-config:view', 'risk-config:update', 'region-block:view', 'region-block:update',
'bd:view', 'bd:create', 'bd:update', 'agency:view', 'agency:create', 'agency:status', 'agency:delete', 'host:view',
'host-agency-policy:view', 'team-salary-policy:view', 'host-salary-settlement:view',
'coin-seller:view', 'coin-seller-sub-application:view', 'host-withdrawal:view', 'point-withdrawal-config:view',
'room:view', 'room:update', 'room:delete', 'room-pin:view', 'room-pin:create', 'room-pin:cancel',
'room-config:view', 'room-config:update', 'room-whitelist:view', 'room-whitelist:update',
'room-robot:view', 'room-robot:create', 'room-robot:update',
'app-config:view', 'app-config:update', 'full-server-notice:send',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'report:view',
'payment-bill:view', 'payment-bill:export', 'payment-bill:refresh',
'payment-third-party:view', 'payment-temporary-link:view', 'payment-product:view',
'daily-task:view', 'first-recharge-reward:view', 'cumulative-recharge-reward:view', 'invite-activity-reward:view',
'seven-day-checkin:view', 'room-rocket:view', 'room-turnover-reward:view', 'wheel:view', 'weekly-star:view',
'agency-opening:view', 'user-leaderboard:view', 'red-packet:view', 'cp-config:view', 'cp-weekly-rank:view', 'vip-config:view', 'achievement:view',
'game-catalog:view', 'self-game:view', 'game-robot:view', 'game-robot:create', 'game-robot:update', 'game-robot:delete',
'room-rps-config:view', 'room-rps-config:update', 'room-rps-order:view',
'country:view', 'region:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'product-lead'
AND permission.code IN (
'overview:view',
'app-user:view', 'app-user:export',
'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant',
'risk-config:view', 'risk-config:update', 'region-block:view', 'region-block:update',
'bd:view', 'agency:view', 'host:view', 'host-agency-policy:view', 'team-salary-policy:view',
'host-salary-settlement:view', 'coin-seller:view', 'coin-seller-sub-application:view', 'host-withdrawal:view', 'point-withdrawal-config:view',
'room:view', 'room:update', 'room:delete', 'room-pin:view', 'room-pin:create', 'room-pin:cancel',
'room-config:view', 'room-config:update', 'room-whitelist:view', 'room-whitelist:update',
'room-robot:view', 'room-robot:create', 'room-robot:update',
'app-config:view', 'app-config:update', 'app-version:view', 'app-version:create', 'app-version:update', 'app-version:delete',
'full-server-notice:view', 'full-server-notice:send',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'report:view', 'gift-diamond:view', 'gift-diamond:update',
'payment-bill:view', 'payment-third-party:view', 'payment-temporary-link:view', 'payment-product:view',
'daily-task:view', 'daily-task:create', 'daily-task:update', 'daily-task:status',
'first-recharge-reward:view', 'first-recharge-reward:update',
'cumulative-recharge-reward:view', 'cumulative-recharge-reward:update',
'invite-activity-reward:view', 'invite-activity-reward:update',
'seven-day-checkin:view', 'seven-day-checkin:update', 'room-rocket:view', 'room-rocket:update',
'room-turnover-reward:view', 'room-turnover-reward:update', 'room-turnover-reward:retry',
'wheel:view', 'wheel:update', 'weekly-star:view', 'weekly-star:create', 'weekly-star:update', 'weekly-star:settle',
'agency-opening:view', 'agency-opening:create', 'agency-opening:update', 'user-leaderboard:view',
'red-packet:view', 'red-packet:update', 'cp-config:view', 'cp-config:update',
'cp-weekly-rank:view', 'cp-weekly-rank:update', 'vip-config:view', 'vip-config:update',
'achievement:view', 'achievement:create', 'achievement:update',
'game-catalog:view', 'game-catalog:platform', 'game-catalog:create', 'game-catalog:update', 'game-catalog:status', 'game-catalog:delete',
'self-game:view', 'self-game:update', 'game-robot:view', 'game-robot:create', 'game-robot:update', 'game-robot:delete',
'room-rps-config:view', 'room-rps-config:update', 'room-rps-order:view', 'room-rps-order:update',
'country:view', 'region:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code = 'product-specialist'
AND permission.code IN (
'overview:view',
'app-user:view', 'app-user:export', 'level-config:view', 'pretty-id:view', 'risk-config:view', 'region-block:view',
'room:view', 'room-pin:view', 'room-config:view', 'room-whitelist:view', 'room-robot:view', 'room-robot:create', 'room-robot:update',
'app-config:view', 'app-version:view', 'full-server-notice:view',
'resource:view', 'resource:create', 'resource:update', 'resource:delete', 'upload:create',
'resource-shop:view', 'resource-shop:update', 'resource-group:view', 'resource-group:create', 'resource-group:update',
'gift:view', 'gift:create', 'gift:update', 'gift:status', 'gift:delete',
'resource-grant:view', 'resource-grant:create', 'resource-grant:revoke', 'emoji-pack:view', 'emoji-pack:create',
'coin-ledger:view', 'coin-seller-ledger:view', 'report:view',
'payment-bill:view', 'payment-third-party:view', 'payment-temporary-link:view', 'payment-product:view',
'daily-task:view', 'first-recharge-reward:view', 'cumulative-recharge-reward:view', 'invite-activity-reward:view',
'seven-day-checkin:view', 'room-rocket:view', 'room-turnover-reward:view', 'wheel:view', 'weekly-star:view',
'agency-opening:view', 'user-leaderboard:view', 'red-packet:view', 'cp-config:view', 'cp-weekly-rank:view', 'vip-config:view', 'achievement:view',
'game-catalog:view', 'self-game:view', 'game-robot:view', 'room-rps-config:view', 'room-rps-order:view',
'country:view', 'region:view'
);
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission
WHERE role.code IN ('finance-lead', 'finance-specialist')
AND permission.code IN (
'overview:view', 'finance:view', 'payment-bill:view', 'payment-bill:export',
'finance-withdrawal:view', 'finance-withdrawal:audit', 'room-rps-order:view'
);

View File

@ -0,0 +1,21 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- App 可见范围与财务区域范围分开保存。已有用户保持历史全 App 行为;新建用户默认无 App
-- 避免创建用户成功但后续授权保存失败时短暂获得全量 App。
ALTER TABLE admin_users
ADD COLUMN app_scope_mode VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT '主后台 App 范围all/selected/none' AFTER status;
ALTER TABLE admin_users
ALTER COLUMN app_scope_mode SET DEFAULT 'none';
CREATE TABLE admin_user_app_scopes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID',
app_code VARCHAR(32) NOT NULL COMMENT '允许在主后台切换的 App 编码',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_user_app_scope (user_id, app_code),
KEY idx_admin_user_app_scopes_user (user_id),
KEY idx_admin_user_app_scopes_app (app_code),
CONSTRAINT fk_admin_user_app_scopes_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户主站 App 可见范围';

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