Merge remote-tracking branch 'origin/main' into test
This commit is contained in:
commit
d6d8dfccb4
@ -7,6 +7,13 @@
|
||||
"runtimeArgs": ["--dir", "/Users/hy/Documents/hy/hyapp-admin-platform", "dev", "--port", "7002"],
|
||||
"port": 7002,
|
||||
"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
@ -1637,6 +1637,21 @@ message UpsertLevelTierResponse {
|
||||
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 是后台等级配置页的配置读取请求;不绑定具体用户进度。
|
||||
message ListLevelConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -2586,6 +2601,7 @@ service AdminGrowthLevelService {
|
||||
rpc UpsertLevelTrack(UpsertLevelTrackRequest) returns (UpsertLevelTrackResponse);
|
||||
rpc UpsertLevelRule(UpsertLevelRuleRequest) returns (UpsertLevelRuleResponse);
|
||||
rpc UpsertLevelTier(UpsertLevelTierRequest) returns (UpsertLevelTierResponse);
|
||||
rpc BatchUpsertLevelConfig(BatchUpsertLevelConfigRequest) returns (BatchUpsertLevelConfigResponse);
|
||||
rpc BatchGetUserLevelAdminProfiles(BatchGetUserLevelAdminProfilesRequest) returns (BatchGetUserLevelAdminProfilesResponse);
|
||||
rpc AdjustTemporaryUserLevels(AdjustTemporaryUserLevelsRequest) returns (AdjustTemporaryUserLevelsResponse);
|
||||
}
|
||||
|
||||
@ -5888,6 +5888,7 @@ const (
|
||||
AdminGrowthLevelService_UpsertLevelTrack_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelTrack"
|
||||
AdminGrowthLevelService_UpsertLevelRule_FullMethodName = "/hyapp.activity.v1.AdminGrowthLevelService/UpsertLevelRule"
|
||||
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_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)
|
||||
UpsertLevelRule(ctx context.Context, in *UpsertLevelRuleRequest, opts ...grpc.CallOption) (*UpsertLevelRuleResponse, 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)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetUserLevelAdminProfilesResponse)
|
||||
@ -5984,6 +5996,7 @@ type AdminGrowthLevelServiceServer interface {
|
||||
UpsertLevelTrack(context.Context, *UpsertLevelTrackRequest) (*UpsertLevelTrackResponse, error)
|
||||
UpsertLevelRule(context.Context, *UpsertLevelRuleRequest) (*UpsertLevelRuleResponse, error)
|
||||
UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error)
|
||||
BatchUpsertLevelConfig(context.Context, *BatchUpsertLevelConfigRequest) (*BatchUpsertLevelConfigResponse, error)
|
||||
BatchGetUserLevelAdminProfiles(context.Context, *BatchGetUserLevelAdminProfilesRequest) (*BatchGetUserLevelAdminProfilesResponse, error)
|
||||
AdjustTemporaryUserLevels(context.Context, *AdjustTemporaryUserLevelsRequest) (*AdjustTemporaryUserLevelsResponse, error)
|
||||
mustEmbedUnimplementedAdminGrowthLevelServiceServer()
|
||||
@ -6008,6 +6021,9 @@ func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelRule(context.Contex
|
||||
func (UnimplementedAdminGrowthLevelServiceServer) UpsertLevelTier(context.Context, *UpsertLevelTierRequest) (*UpsertLevelTierResponse, error) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
in := new(BatchGetUserLevelAdminProfilesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -6167,6 +6201,10 @@ var AdminGrowthLevelService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpsertLevelTier",
|
||||
Handler: _AdminGrowthLevelService_UpsertLevelTier_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchUpsertLevelConfig",
|
||||
Handler: _AdminGrowthLevelService_BatchUpsertLevelConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetUserLevelAdminProfiles",
|
||||
Handler: _AdminGrowthLevelService_BatchGetUserLevelAdminProfiles_Handler,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -49,6 +49,8 @@ message GameCatalogItem {
|
||||
int64 created_at_ms = 15;
|
||||
int64 updated_at_ms = 16;
|
||||
int32 safe_height = 17;
|
||||
// whitelist_enabled 关闭时保持历史全量可见语义;开启后只向 game_user_whitelist 中的用户展示并允许启动。
|
||||
bool whitelist_enabled = 18;
|
||||
}
|
||||
|
||||
message AppGame {
|
||||
@ -682,6 +684,53 @@ message DiceRobotResponse {
|
||||
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 {
|
||||
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
|
||||
rpc ListRecentGames(ListRecentGamesRequest) returns (ListGamesResponse);
|
||||
@ -718,6 +767,10 @@ service GameAdminService {
|
||||
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
|
||||
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
|
||||
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 UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse);
|
||||
rpc GetSelfGameNewUserPolicy(GetSelfGameNewUserPolicyRequest) returns (SelfGameNewUserPolicyResponse);
|
||||
|
||||
@ -943,6 +943,10 @@ const (
|
||||
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
|
||||
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
|
||||
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_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
|
||||
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)
|
||||
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, 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)
|
||||
UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListSelfGamesResponse)
|
||||
@ -1239,6 +1287,10 @@ type GameAdminServiceServer interface {
|
||||
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
|
||||
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, 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)
|
||||
UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, 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) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
in := new(ListSelfGamesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1801,6 +1937,22 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DeleteCatalog",
|
||||
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",
|
||||
Handler: _GameAdminService_ListSelfGames_Handler,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Fami VIP Flutter 对接方案
|
||||
# 可配置 VIP 权限体系 Flutter 对接方案(Fami / Lalu)
|
||||
|
||||
本文只定义 Flutter 与已落地后端的对接契约,不要在客户端重复实现 VIP 状态机。界面和文案统一使用“VIP”,不再使用“SVIP”。
|
||||
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”,不再使用“SVIP”。
|
||||
|
||||
## 1. 通用约定
|
||||
|
||||
@ -21,7 +21,178 @@
|
||||
- 所有 `*_at_ms` / `duration_ms` 都是 Unix epoch milliseconds。业务切日使用 UTC,Flutter 只在展示层转本地时间。
|
||||
- 客户端展示当前身份只读 `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 StoreKit:currentEntitlements](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 都有 VIP1–VIP9。
|
||||
- 套餐是否可买使用 `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 权益计算。
|
||||
- `current_vip` 是兼容字段,新代码优先使用 `state.effective_vip`。
|
||||
|
||||
## 3. 当前 VIP 状态
|
||||
## 7. 当前 VIP 状态
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -278,7 +449,7 @@
|
||||
- 无 VIP 时,`effective_source="none"`、`effective_vip.active=false`、`effective_benefits=[]`。
|
||||
- `user_settings.updated_at_ms=0` 表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
|
||||
|
||||
## 4. 购买、续期和升级
|
||||
## 8. 购买、续期和升级
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -381,7 +552,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
|
||||
- 余额不足返回 `INSUFFICIENT_BALANCE`;等级未启用返回 `VIP_LEVEL_DISABLED`。
|
||||
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准。
|
||||
|
||||
## 5. 体验卡背包列表
|
||||
## 9. 体验卡背包列表
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -449,7 +620,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
|
||||
- 列表仅返回当前有效的背包实例。卡过期后会自然从 active 列表消失。
|
||||
- 预置卡素材初始为空,Flutter 需提供无图兜底,不得拼接本地固定素材 URL。
|
||||
|
||||
## 6. 佩戴体验卡
|
||||
## 10. 佩戴体验卡
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -518,7 +689,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
|
||||
- 倒计时使用 `expires_at_ms - server_time_ms`,不要在每次切换时重置为 `duration_ms`。
|
||||
- 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"`。
|
||||
|
||||
## 8. 经理中心发放 VIP
|
||||
## 12. 经理中心发放 VIP
|
||||
|
||||
经理中心页面如果复用 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`。
|
||||
- 配置恰好在 gateway 查询后被后台切换时,wallet 会拒绝旧模式写入;Flutter 展示接口错误并允许使用同一 `command_id` 重试。
|
||||
|
||||
## 9. 进房通知 IM
|
||||
## 13. 进房通知 IM
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -727,7 +898,7 @@ Fami(`trial_card`)发一张 30 天体验卡到背包,不自动佩戴:
|
||||
- 用 `event_id` 去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。
|
||||
- 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
|
||||
|
||||
## 10. VIP 通知开关
|
||||
## 14. VIP 通知开关
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -809,7 +980,7 @@ PATCH:
|
||||
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
|
||||
- 设置按 App 隔离,Fami 的修改不影响 Lalu。
|
||||
|
||||
## 11. 每日 VIP 金币返现
|
||||
## 15. 每日 VIP 金币返现
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -989,7 +1160,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
|
||||
- 领取成功后用 `data.coin_balance` 替换 COIN 余额快照,不在 Flutter 本地做 `balance += coin_amount`。
|
||||
- 记录不存在或不属于当前用户时返回 `VIP_COIN_REBATE_NOT_FOUND`;复用一个 `command_id` 改投另一笔返现时返回 `IDEMPOTENCY_CONFLICT`。
|
||||
|
||||
## 12. 上线全服通知
|
||||
## 16. 上线全服通知
|
||||
|
||||
### 接口地址
|
||||
|
||||
@ -1078,7 +1249,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
|
||||
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `PERMISSION_DENIED`;Flutter 不做本地飘屏,可重拉 `/vip/me`。
|
||||
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
|
||||
|
||||
## 13. 权益场景和完成边界
|
||||
## 17. 权益场景和完成边界
|
||||
|
||||
| 场景 | Flutter 处理 | 当前后端状态 |
|
||||
|---|---|---|
|
||||
@ -1097,7 +1268,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
|
||||
| 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM;服务端不做日级去重 |
|
||||
| 其他 VIP 装扮/功能 | 可根据 `effective_benefits` 展示锁定/解锁态;有素材时使用后端 URL | 权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 `resource_id=0`、素材为空,未绑定 owner/资源前不能宣称已实现 |
|
||||
|
||||
## 14. Flutter 建议状态流
|
||||
## 18. Flutter 建议状态流
|
||||
|
||||
1. 进 VIP 页并发请求 `/vip/packages` 和 `/vip/me`,套餐用前者,用户生效状态用后者。
|
||||
2. 购买成功、佩戴成功、卸下成功时,立即用响应中的 `state` 替换全局 VIP store。
|
||||
|
||||
@ -134,7 +134,10 @@ X-App-Code: lalu
|
||||
"user_id": "10001",
|
||||
"display_user_id": "888888",
|
||||
"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
|
||||
@ -358,12 +363,14 @@ class LeaderboardUser {
|
||||
required this.displayUserId,
|
||||
required this.username,
|
||||
required this.avatar,
|
||||
required this.shortBadgeCoverUrls,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String displayUserId;
|
||||
final String username;
|
||||
final String avatar;
|
||||
final List<String> shortBadgeCoverUrls;
|
||||
|
||||
factory LeaderboardUser.fromJson(Map<String, dynamic> json) {
|
||||
return LeaderboardUser(
|
||||
@ -371,6 +378,9 @@ class LeaderboardUser {
|
||||
displayUserId: json['display_user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
shortBadgeCoverUrls: (json['short_badge_cover_urls'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,6 +70,8 @@ var catalog = map[Code]Spec{
|
||||
DeviceAlreadyRegistered,
|
||||
"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"),
|
||||
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
|
||||
|
||||
@ -75,6 +75,10 @@ const (
|
||||
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
||||
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
|
||||
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 Code = "INSUFFICIENT_BALANCE"
|
||||
|
||||
@ -119,6 +119,22 @@ func TestCatalogMappings(t *testing.T) {
|
||||
publicCode: string(DeviceAlreadyRegistered),
|
||||
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",
|
||||
code: RegionMismatch,
|
||||
|
||||
43
scripts/mysql/066_app_logo_url.sql
Normal file
43
scripts/mysql/066_app_logo_url.sql
Normal 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;
|
||||
555
scripts/zgame-flutter-container-check.py
Executable file
555
scripts/zgame-flutter-container-check.py
Executable 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_code,adapterType 则决定安装 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
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"hyapp-admin-server/internal/cache"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
|
||||
"hyapp-admin-server/internal/integration/gameclient"
|
||||
"hyapp-admin-server/internal/integration/googleorders"
|
||||
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||
@ -44,13 +43,13 @@ import (
|
||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||
databimodule "hyapp-admin-server/internal/modules/databi"
|
||||
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
||||
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
|
||||
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
|
||||
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond"
|
||||
giftrecordmodule "hyapp-admin-server/internal/modules/giftrecord"
|
||||
healthmodule "hyapp-admin-server/internal/modules/health"
|
||||
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
|
||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -320,24 +319,6 @@ func main() {
|
||||
}
|
||||
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 服务实例,保证外部源路由和统计口径一致。
|
||||
dashboardService := dashboardmodule.NewService(
|
||||
store,
|
||||
@ -365,7 +346,7 @@ func main() {
|
||||
appRegistryService,
|
||||
userclient.NewGRPC(userConn),
|
||||
databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
|
||||
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
|
||||
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources, cfg.MoneyRegionSources)...),
|
||||
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
|
||||
)
|
||||
luckyGiftHandler := luckygiftmodule.New(luckygiftadmin.NewGRPC(luckyGiftConn), cfg.LuckyGiftService.RequestTimeout, auditHandler)
|
||||
@ -376,7 +357,7 @@ func main() {
|
||||
AgencyOpening: agencyopeningmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppRegistry: appregistrymodule.New(userDB, store),
|
||||
AppUser: appUserHandler,
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryRegionHandler,
|
||||
@ -387,10 +368,14 @@ func main() {
|
||||
Dashboard: dashboardmodule.NewWithService(dashboardService),
|
||||
Databi: databimodule.New(databiService, store, 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, financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService), financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
||||
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler,
|
||||
financeordermodule.WithFinanceCoinSellerRechargeStatsRecorder(dashboardService),
|
||||
financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...),
|
||||
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(
|
||||
gameclient.NewGRPC(gameConn),
|
||||
userclient.NewGRPC(userConn),
|
||||
@ -399,6 +384,7 @@ func main() {
|
||||
gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)),
|
||||
),
|
||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||
GiftRecord: giftrecordmodule.New(userDB, walletDB),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, 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),
|
||||
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{
|
||||
Addr: cfg.HTTPAddr,
|
||||
@ -657,10 +643,17 @@ func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, config
|
||||
}
|
||||
|
||||
// databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录;
|
||||
// 同一 App 配多个源(如 cdc MySQL + Mongo 补充)时只取一次。
|
||||
func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimodule.LegacyAppDescriptor {
|
||||
// Logo 复用财务范围的 legacy App 目录,避免 dashboard、账单和 Social BI 各自维护同一份品牌元数据。
|
||||
func databiLegacyApps(configs []config.DashboardExternalSourceConfig, regionConfigs []config.MoneyRegionSourceConfig) []databimodule.LegacyAppDescriptor {
|
||||
out := []databimodule.LegacyAppDescriptor{}
|
||||
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 {
|
||||
if !sourceConfig.Enabled {
|
||||
continue
|
||||
@ -673,7 +666,11 @@ func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimo
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ money_region_sources:
|
||||
- enabled: true
|
||||
app_code: "yumi"
|
||||
app_name: "Yumi"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
|
||||
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_database: "tarab_all"
|
||||
@ -26,6 +27,7 @@ money_region_sources:
|
||||
- enabled: true
|
||||
app_code: "aslan"
|
||||
app_name: "Aslan"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
|
||||
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_database: "atyou"
|
||||
@ -36,6 +38,7 @@ finance_bill_sources:
|
||||
- enabled: true
|
||||
app_code: "yumi"
|
||||
app_name: "Yumi"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
|
||||
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_database: "tarab_all"
|
||||
@ -47,6 +50,7 @@ finance_bill_sources:
|
||||
- enabled: true
|
||||
app_code: "aslan"
|
||||
app_name: "Aslan"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
|
||||
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_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"
|
||||
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"
|
||||
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:
|
||||
enabled: true
|
||||
secret-id: "REPLACE_ME"
|
||||
|
||||
@ -18,6 +18,7 @@ money_region_sources:
|
||||
- enabled: false
|
||||
app_code: "yumi"
|
||||
app_name: "Yumi"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
|
||||
sys_origin: "LIKEI"
|
||||
mongo_uri: ""
|
||||
mongo_database: "tarab_all"
|
||||
@ -26,6 +27,7 @@ money_region_sources:
|
||||
- enabled: false
|
||||
app_code: "aslan"
|
||||
app_name: "Aslan"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
|
||||
sys_origin: "ATYOU"
|
||||
mongo_uri: ""
|
||||
mongo_database: "atyou"
|
||||
@ -36,6 +38,7 @@ finance_bill_sources:
|
||||
- enabled: false
|
||||
app_code: "yumi"
|
||||
app_name: "Yumi"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
|
||||
sys_origin: "LIKEI"
|
||||
mongo_uri: ""
|
||||
mongo_database: "tarab_all"
|
||||
@ -47,6 +50,7 @@ finance_bill_sources:
|
||||
- enabled: false
|
||||
app_code: "aslan"
|
||||
app_name: "Aslan"
|
||||
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
|
||||
sys_origin: "ATYOU"
|
||||
mongo_uri: ""
|
||||
mongo_database: "atyou"
|
||||
@ -144,14 +148,6 @@ legacy_coin_seller_recharge_sources:
|
||||
app_mysql_dsn: ""
|
||||
wallet_mysql_dsn: ""
|
||||
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:
|
||||
enabled: true
|
||||
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
||||
|
||||
@ -36,6 +36,16 @@
|
||||
- `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` 使用以下值:
|
||||
@ -328,48 +338,54 @@
|
||||
|
||||
## 角色预设
|
||||
|
||||
### 平台管理员
|
||||
### 超级管理员
|
||||
|
||||
`platform-admin`
|
||||
|
||||
- 拥有全部权限。
|
||||
- 能维护用户、角色、权限、菜单和所有业务模块。
|
||||
|
||||
### 运维管理员
|
||||
### 运营负责人
|
||||
|
||||
`ops-admin`
|
||||
|
||||
建议权限:
|
||||
- 负责运营、团队、房间、资源、活动和游戏的日常管理。
|
||||
- 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。
|
||||
|
||||
- `overview:view`
|
||||
- `service:view`
|
||||
- `service:create`
|
||||
- `service:update`
|
||||
- `service:operate`
|
||||
- `user:view`
|
||||
- `user:status`
|
||||
- `log:view`
|
||||
### 运营专员
|
||||
|
||||
### 审计员
|
||||
`operations-specialist`
|
||||
|
||||
`auditor`
|
||||
- 可执行用户、团队结构、房间、资源和常规运营动作。
|
||||
- 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。
|
||||
|
||||
建议权限:
|
||||
### 产品负责人
|
||||
|
||||
- `overview:view`
|
||||
- `log:view`
|
||||
- `user:view`
|
||||
- `role:view`
|
||||
- `permission:view`
|
||||
`product-lead`
|
||||
|
||||
### 只读用户
|
||||
- 可管理 APP 配置、版本、房间、资源、活动和游戏。
|
||||
- 团队、支付、地区和 App 用户主资料按表格保持只读或限定动作。
|
||||
|
||||
`readonly`
|
||||
### 产品专员
|
||||
|
||||
建议权限:
|
||||
`product-specialist`
|
||||
|
||||
- 所有已开放模块的 `*:view`
|
||||
- 不包含创建、更新、删除、导出、状态变更、重置密码、授权等按钮权限。
|
||||
- 负责资源配置;房间仅可管理机器人配置。
|
||||
- APP 配置、活动、游戏、支付和地区模块保持只读。
|
||||
|
||||
### 财务负责人
|
||||
|
||||
`finance-lead`
|
||||
|
||||
- 只进入财务看板、充值对账、提现审核和房内猜拳订单。
|
||||
|
||||
### 财务专员
|
||||
|
||||
`finance-specialist`
|
||||
|
||||
- 当前与财务负责人使用同一权限矩阵;保留独立角色用于人员分工和后续数据范围配置。
|
||||
|
||||
固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor`、`readonly` 和用户自建角色不会被删除或重置。
|
||||
|
||||
## 前端落地规则
|
||||
|
||||
@ -535,15 +551,13 @@
|
||||
- 创建、更新、删除、授权、导出、批量和状态类操作都有操作日志。
|
||||
- 全局搜索结果按当前用户权限过滤。
|
||||
- `platform-admin` 拥有所有权限。
|
||||
- `readonly` 只有查看权限,不能触发任何写操作。
|
||||
- 六个岗位角色的权限与 `093_role_permission_matrix.sql` 及后端默认矩阵完全一致。
|
||||
- 游戏列表、自研游戏、全站机器人、房内猜拳配置和房内猜拳订单互不借用查看或更新权限。
|
||||
- 支付账单导出/刷新、支付方式更新/同步、汇率编辑/同步互不借用列表查看权限。
|
||||
|
||||
## 实施顺序
|
||||
## 维护规则
|
||||
|
||||
1. 拆分并补齐权限种子:新增 `permission:*`、`menu:*`、`role:create/update/delete/permission`、`user:export`、`log:export`。
|
||||
2. 后端补权限定义和菜单管理接口。
|
||||
3. 后端把现有接口从宽权限调整到按钮级权限。
|
||||
4. 前端补角色授权抽屉和菜单权限页编辑能力。
|
||||
5. 前端替换按钮权限码,导出、已读等动作不再复用查看权限。
|
||||
6. 补默认角色权限绑定策略。
|
||||
7. 补搜索结果权限过滤。
|
||||
8. 补后端权限相关测试和前端构建验证。
|
||||
1. 新增页面时先定义独立的 `*:view`,再按真实副作用拆按钮权限。
|
||||
2. 不允许为了复用一个 Handler,把相邻菜单重新绑定到同一个宽权限。
|
||||
3. 固定岗位矩阵变更必须同时更新默认种子、版本化迁移和矩阵一致性测试。
|
||||
4. 旧权限码只能作为自定义历史角色的兼容入口,不能重新分配给七类固定岗位。
|
||||
|
||||
@ -49,7 +49,6 @@ type Config struct {
|
||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
||||
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
||||
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
|
||||
}
|
||||
|
||||
type MigrationConfig struct {
|
||||
@ -127,19 +126,6 @@ type LegacyCoinSellerRechargeSourceConfig struct {
|
||||
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 {
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
@ -149,6 +135,7 @@ type MoneyRegionSourceConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
AppCode string `yaml:"app_code"`
|
||||
AppName string `yaml:"app_name"`
|
||||
LogoURL string `yaml:"logo_url"`
|
||||
SysOrigin string `yaml:"sys_origin"`
|
||||
MongoURI string `yaml:"mongo_uri"`
|
||||
MongoDatabase string `yaml:"mongo_database"`
|
||||
@ -162,6 +149,7 @@ type FinanceBillSourceConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
AppCode string `yaml:"app_code"`
|
||||
AppName string `yaml:"app_name"`
|
||||
LogoURL string `yaml:"logo_url"`
|
||||
SysOrigin string `yaml:"sys_origin"`
|
||||
MongoURI string `yaml:"mongo_uri"`
|
||||
MongoDatabase string `yaml:"mongo_database"`
|
||||
@ -237,6 +225,7 @@ func Default() Config {
|
||||
Enabled: false,
|
||||
AppCode: "yumi",
|
||||
AppName: "Yumi",
|
||||
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
|
||||
SysOrigin: "LIKEI",
|
||||
MongoDatabase: "tarab_all",
|
||||
MongoCollection: "sys_region_config",
|
||||
@ -246,6 +235,7 @@ func Default() Config {
|
||||
Enabled: false,
|
||||
AppCode: "aslan",
|
||||
AppName: "Aslan",
|
||||
LogoURL: "https://media.haiyihy.com/admin/apps/logos/aslan.png",
|
||||
SysOrigin: "ATYOU",
|
||||
MongoDatabase: "tarab_all",
|
||||
MongoCollection: "sys_region_config",
|
||||
@ -257,6 +247,7 @@ func Default() Config {
|
||||
Enabled: false,
|
||||
AppCode: "yumi",
|
||||
AppName: "Yumi",
|
||||
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
|
||||
SysOrigin: "LIKEI",
|
||||
MongoDatabase: "tarab_all",
|
||||
MongoCollection: "in_app_purchase_details",
|
||||
@ -266,6 +257,7 @@ func Default() Config {
|
||||
Enabled: false,
|
||||
AppCode: "aslan",
|
||||
AppName: "Aslan",
|
||||
LogoURL: "https://media.haiyihy.com/admin/apps/logos/aslan.png",
|
||||
// Aslan(likei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。
|
||||
SysOrigin: "ATYOU",
|
||||
MongoDatabase: "atyou",
|
||||
@ -366,12 +358,6 @@ func Default() Config {
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
},
|
||||
FinanceNotifications: FinanceNotificationsConfig{
|
||||
DingTalk: DingTalkRobotConfig{
|
||||
Enabled: true,
|
||||
RequestTimeout: 3 * time.Second,
|
||||
},
|
||||
},
|
||||
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
||||
Enabled: true,
|
||||
PollInterval: 15 * time.Second,
|
||||
@ -569,13 +555,6 @@ func (cfg *Config) Normalize() {
|
||||
}
|
||||
}
|
||||
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)
|
||||
if cfg.RobotProfileSource.RequestTimeout <= 0 {
|
||||
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
|
||||
@ -585,6 +564,7 @@ func (cfg *Config) Normalize() {
|
||||
// legacy 区域来源只服务财务范围目录;这里先规整配置,handler 层只处理业务过滤,不重复处理大小写和默认集合名。
|
||||
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
|
||||
source.AppName = strings.TrimSpace(source.AppName)
|
||||
source.LogoURL = strings.TrimSpace(source.LogoURL)
|
||||
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
|
||||
source.MongoURI = strings.TrimSpace(source.MongoURI)
|
||||
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
|
||||
@ -605,6 +585,7 @@ func (cfg *Config) Normalize() {
|
||||
// legacy 账单源只服务财务充值明细;配置层统一大小写和默认集合,handler 只按 app_code 命中。
|
||||
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
|
||||
source.AppName = strings.TrimSpace(source.AppName)
|
||||
source.LogoURL = strings.TrimSpace(source.LogoURL)
|
||||
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
|
||||
source.MongoURI = strings.TrimSpace(source.MongoURI)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) == "" {
|
||||
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
|
||||
}
|
||||
@ -917,26 +890,6 @@ func (cfg Config) Validate() error {
|
||||
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() {
|
||||
for index := range cfg.DashboardExternalSources {
|
||||
source := &cfg.DashboardExternalSources[index]
|
||||
|
||||
@ -34,12 +34,6 @@ func TestLoadConfigYAML(t *testing.T) {
|
||||
if !cfg.TencentCOS.Enabled || cfg.TencentCOS.BucketName != "yumi-assets-1420526837" || cfg.TencentCOS.ObjectPrefix != "admin" {
|
||||
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) {
|
||||
@ -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) {
|
||||
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")
|
||||
|
||||
@ -63,6 +63,7 @@ type Client interface {
|
||||
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
|
||||
UpsertLevelRule(ctx context.Context, req *activityv1.UpsertLevelRuleRequest) (*activityv1.UpsertLevelRuleResponse, 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)
|
||||
CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return c.levelClient.SetUserLevel(ctx, req)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,10 @@ type Client interface {
|
||||
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
|
||||
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, 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)
|
||||
UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return c.client.ListSelfGames(ctx, req)
|
||||
}
|
||||
|
||||
52
server/admin/internal/middleware/app_scope_test.go
Normal file
52
server/admin/internal/middleware/app_scope_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/config"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/response"
|
||||
"hyapp-admin-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AppAccessStore interface {
|
||||
AppAccessForUser(userID uint) (repository.AppAccess, error)
|
||||
}
|
||||
|
||||
const (
|
||||
ContextUserID = "userID"
|
||||
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 {
|
||||
return func(c *gin.Context) {
|
||||
if HasPermission(c, code) {
|
||||
|
||||
@ -5,6 +5,10 @@ const (
|
||||
UserStatusLocked = "locked"
|
||||
UserStatusDisabled = "disabled"
|
||||
|
||||
UserAppScopeModeAll = "all"
|
||||
UserAppScopeModeSelected = "selected"
|
||||
UserAppScopeModeNone = "none"
|
||||
|
||||
TeamProductResearchID uint = 1
|
||||
TeamOperationsID uint = 2
|
||||
|
||||
@ -14,10 +18,6 @@ const (
|
||||
JobStatusFailed = "failed"
|
||||
JobStatusCanceled = "canceled"
|
||||
|
||||
FinanceApplicationStatusPending = "pending"
|
||||
FinanceApplicationStatusApproved = "approved"
|
||||
FinanceApplicationStatusRejected = "rejected"
|
||||
|
||||
CoinSellerRechargeOrderStatusCreated = "created"
|
||||
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
|
||||
CoinSellerRechargeOrderStatusVerified = "verified"
|
||||
@ -48,6 +48,7 @@ type User struct {
|
||||
TeamID *uint `gorm:"column:team_id;index" json:"teamId"`
|
||||
TeamRecord Team `gorm:"foreignKey:TeamID;references:ID" json:"-"`
|
||||
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"`
|
||||
LastLoginAtMS *int64 `gorm:"column:last_login_at_ms" json:"lastLoginAtMs"`
|
||||
Roles []Role `gorm:"many2many:admin_user_roles;" json:"roles"`
|
||||
@ -516,6 +517,20 @@ func (UserMoneyScope) TableName() string {
|
||||
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 {
|
||||
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
|
||||
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`
|
||||
@ -567,36 +582,6 @@ func (LegacyGooglePaidSyncAttempt) TableName() string {
|
||||
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 钱包账套持有。
|
||||
type CoinSellerRechargeOrder struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@ -86,6 +86,26 @@ func (h *Handler) ListUserMoneyScopes(c *gin.Context) {
|
||||
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) {
|
||||
id, ok := shared.ParseID(c, "id")
|
||||
if !ok {
|
||||
@ -109,6 +129,42 @@ func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
|
||||
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) {
|
||||
id, ok := shared.ParseID(c, "id")
|
||||
if !ok {
|
||||
@ -210,3 +266,11 @@ func moneyScopeDTOs(scopes []model.UserMoneyScope) []gin.H {
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
@ -41,3 +41,8 @@ type moneyScopeItemRequest struct {
|
||||
AppCode string `json:"appCode"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
}
|
||||
|
||||
type appScopeRequest struct {
|
||||
Mode string `json:"mode" binding:"required"`
|
||||
AppCodes []string `json:"appCodes"`
|
||||
}
|
||||
|
||||
@ -11,10 +11,13 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.POST("/users", middleware.RequirePermission("user:create"), h.CreateUser)
|
||||
protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers)
|
||||
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/app-scopes", middleware.RequirePermission("user:view"), h.GetUserAppScopes)
|
||||
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.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/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
|
||||
protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus)
|
||||
|
||||
@ -65,6 +65,11 @@ type MoneyScopeInput struct {
|
||||
RegionID int64
|
||||
}
|
||||
|
||||
type AppScopeInput struct {
|
||||
Mode string
|
||||
AppCodes []string
|
||||
}
|
||||
|
||||
func NewService(store *repository.Store, cfg config.Config) *AdminUserService {
|
||||
return &AdminUserService{store: store, cfg: cfg}
|
||||
}
|
||||
@ -98,6 +103,8 @@ func (s *AdminUserService) CreateUser(input CreateUserInput) (*model.User, Passw
|
||||
Team: teamName,
|
||||
TeamID: teamID,
|
||||
Status: shared.DefaultString(input.Status, model.UserStatusActive),
|
||||
// 新用户先以零 App 权限落库,后续独立 scope 请求即使失败也不会产生短暂的全 App 越权窗口。
|
||||
AppScopeMode: model.UserAppScopeModeNone,
|
||||
MFAEnabled: input.MFAEnabled,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
scopes := make([]model.UserMoneyScope, 0, len(input))
|
||||
for _, item := range input {
|
||||
@ -132,6 +175,14 @@ func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInp
|
||||
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) {
|
||||
updates := map[string]any{}
|
||||
if input.Name != nil {
|
||||
|
||||
@ -3,6 +3,8 @@ package appregistry
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -10,10 +12,11 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
store *repository.Store
|
||||
}
|
||||
|
||||
func New(userDB *sql.DB) *Handler {
|
||||
return &Handler{service: NewService(userDB)}
|
||||
func New(userDB *sql.DB, store *repository.Store) *Handler {
|
||||
return &Handler{service: NewService(userDB), store: store}
|
||||
}
|
||||
|
||||
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)})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
}
|
||||
|
||||
protected.GET("/admin/apps", h.ListApps)
|
||||
protected.GET("/admin/apps/visible", h.ListVisibleApps)
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ type App struct {
|
||||
AppID int64 `json:"appId"`
|
||||
AppCode string `json:"appCode"`
|
||||
AppName string `json:"appName"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
PackageName string `json:"packageName"`
|
||||
Platform string `json:"platform"`
|
||||
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")
|
||||
}
|
||||
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
|
||||
WHERE status = 'active'
|
||||
ORDER BY app_name ASC, app_code ASC
|
||||
@ -43,7 +44,7 @@ func (s *Service) ListApps(ctx context.Context) ([]App, error) {
|
||||
items := []App{}
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
items = append(items, item)
|
||||
|
||||
31
server/admin/internal/modules/appregistry/service_test.go
Normal file
31
server/admin/internal/modules/appregistry/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -54,6 +54,25 @@ type AslanMongoDashboardSource struct {
|
||||
goldWaterMu sync.Mutex
|
||||
goldWaterChecked 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 {
|
||||
@ -414,7 +433,7 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim
|
||||
)
|
||||
}
|
||||
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 {
|
||||
return aslanRangeOverview{}, err
|
||||
@ -1285,13 +1304,41 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRetention 用注册 cohort × user_daily_active_log 推 D1/D7/D30:目标观察日未到时基数保持 0,
|
||||
// 上层会把 0 基数转成 nil,避免把“还不能观察”展示成真实 0% 留存。
|
||||
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
|
||||
// applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30:
|
||||
// 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D
|
||||
//(与 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)
|
||||
if err != nil {
|
||||
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))
|
||||
offsets := []struct {
|
||||
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 }},
|
||||
{30, func(metric *legacyMongoMetric, users, base int64) { metric.D30Users, metric.D30Base = users, base }},
|
||||
}
|
||||
for day, byCountry := range cohorts {
|
||||
if !grid.hasDay(day) {
|
||||
for _, day := range grid.days {
|
||||
if day > today {
|
||||
continue
|
||||
}
|
||||
dayTime, err := time.ParseInLocation("2006-01-02", day, location)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
allIDs := []int64{}
|
||||
for _, userIDs := range byCountry {
|
||||
allIDs = append(allIDs, userIDs...)
|
||||
// 三个 offset 的 cohort 用户并集只查一次活跃集合,把每天的 Mongo 点查从 3 次收敛到 1 次。
|
||||
cohortsByOffset := make([]map[string][]int64, len(offsets))
|
||||
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
|
||||
}
|
||||
for _, offset := range offsets {
|
||||
target := dashboardSQLDate(dayTime.AddDate(0, 0, offset.days))
|
||||
if target > today {
|
||||
continue
|
||||
}
|
||||
activeSet, err := s.loadActiveUserSet(ctx, target, allIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for countryCode, userIDs := range byCountry {
|
||||
activeSet, err := s.activeUserSet(ctx, day, unionIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for index, offset := range offsets {
|
||||
for countryCode, userIDs := range cohortsByOffset[index] {
|
||||
if len(userIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
active := int64(0)
|
||||
for _, userID := range userIDs {
|
||||
if _, ok := activeSet[userID]; ok {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// 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}=6;06-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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
|
||||
protected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview)
|
||||
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
|
||||
protected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
|
||||
protected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)
|
||||
protected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
|
||||
func RegisterRoutes(appProtected *gin.RouterGroup, workspaceProtected *gin.RouterGroup, h *Handler) {
|
||||
// 主后台画像严格跟随用户 App scope;独立运营工作台继续使用自身的查询 App 和数据范围,允许零主站 App 用户进入通用入口。
|
||||
appProtected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
|
||||
appProtected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview)
|
||||
workspaceProtected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
|
||||
workspaceProtected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
|
||||
workspaceProtected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)
|
||||
workspaceProtected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
|
||||
}
|
||||
|
||||
@ -114,12 +114,10 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
|
||||
// login_audit 的复合索引把 login_type 放在 created_at_ms 之前;若把三种登录类型写成 IN,
|
||||
// MySQL 必须为每个用户扫描并排序全部候选,线上百万级审计表会超时。这里按类型拆成三个等值索引
|
||||
// 范围,每个范围只取一条,再对最多三条候选按 (created_at_ms, id) 选最新记录,既保持原口径,
|
||||
// 也避免窗口函数物化整张审计表。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
|
||||
// 也避免窗口函数物化整张审计表。版本过滤必须放在 latest 选定之后:若先过滤审计行,最新登录
|
||||
// 没带版本的用户会错误回退到旧版本。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
|
||||
rows, err := s.userDB.QueryContext(ctx, `
|
||||
SELECT CASE
|
||||
WHEN latest.app_version IS NULL OR TRIM(latest.app_version) = '' THEN 'unknown'
|
||||
ELSE TRIM(latest.app_version)
|
||||
END AS version_key,
|
||||
SELECT TRIM(latest.app_version) AS version_key,
|
||||
COUNT(*) AS user_count
|
||||
FROM users AS natural_user
|
||||
LEFT JOIN LATERAL (
|
||||
@ -161,6 +159,9 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
|
||||
WHERE natural_user.app_code = ?
|
||||
AND natural_user.profile_completed = 1
|
||||
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
|
||||
ORDER BY user_count DESC, version_key ASC`, appCode)
|
||||
if err != nil {
|
||||
@ -176,7 +177,11 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
@ -206,10 +211,3 @@ func genderDistributionLabel(key string) string {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func appVersionDistributionLabel(key string) string {
|
||||
if key == "unknown" {
|
||||
return "未知版本"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@ -50,6 +50,8 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
|
||||
('lalu', 6, 'male', 1, 'game_robot'),
|
||||
('lalu', 7, 'female', 0, 'third_party'),
|
||||
('lalu', 9, 'female', 1, 'third_party'),
|
||||
('lalu', 10, 'male', 1, 'password'),
|
||||
('lalu', 11, 'female', 1, 'password'),
|
||||
('huwaa', 1, 'female', 1, 'third_party')`,
|
||||
`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),
|
||||
@ -65,6 +67,8 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
|
||||
('lalu', 6, 'password', 'excluded', 'success', 0, 500),
|
||||
('lalu', 7, 'password', 'excluded', '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)`,
|
||||
)
|
||||
|
||||
@ -74,10 +78,9 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("query real MySQL version distribution: %v", err)
|
||||
}
|
||||
if len(items) != 3 ||
|
||||
items[0] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) ||
|
||||
items[1] != (UserProfileDistributionItem{Key: "3.0.0", Label: "3.0.0", Count: 1}) ||
|
||||
items[2] != (UserProfileDistributionItem{Key: "time-wins", Label: "time-wins", Count: 1}) {
|
||||
if len(items) != 2 ||
|
||||
items[0] != (UserProfileDistributionItem{Key: "3.0.0", Label: "3.0.0", Count: 1}) ||
|
||||
items[1] != (UserProfileDistributionItem{Key: "time-wins", Label: "time-wins", Count: 1}) {
|
||||
t.Fatalf("version distribution mismatch: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.
|
||||
AddRow("male", int64(4)).
|
||||
AddRow("female", int64(2)).
|
||||
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").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version_key", "user_count"}).
|
||||
AddRow("2.4.0", int64(4)).
|
||||
@ -84,12 +84,11 @@ func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.
|
||||
envelope.Data.GenderDistribution[2] != (UserProfileDistributionItem{Key: "unknown", Label: "未知", Count: 1}) {
|
||||
t.Fatalf("gender distribution mismatch: %+v", envelope.Data.GenderDistribution)
|
||||
}
|
||||
if len(envelope.Data.AppVersionDistribution) != 2 ||
|
||||
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}) {
|
||||
if len(envelope.Data.AppVersionDistribution) != 1 ||
|
||||
envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) {
|
||||
t.Fatalf("version distribution mismatch: %+v", envelope.Data.AppVersionDistribution)
|
||||
}
|
||||
// 版本分布以全部自然用户为分母并直接查询 login_audit;statistics-service 只保留新增/DAU 口径。
|
||||
// 版本分布直接查询 login_audit 且只返回有最近登录版本的自然用户;statistics-service 只保留新增/DAU 口径。
|
||||
if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] {
|
||||
t.Fatalf("statistics calls mismatch: %+v", requested)
|
||||
}
|
||||
@ -146,7 +145,7 @@ func TestUserProfileOverviewRecordsInternalErrorAgainstRequestID(t *testing.T) {
|
||||
recordedErrors = c.Errors.String()
|
||||
})
|
||||
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)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@ -173,6 +172,6 @@ func newDashboardUserProfileTestRouter(handler *Handler, permissions []string) *
|
||||
c.Next()
|
||||
})
|
||||
protected := router.Group("/api/v1")
|
||||
RegisterRoutes(protected, handler)
|
||||
RegisterRoutes(protected, protected, handler)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ type LegacyRegionCatalogSource interface {
|
||||
type LegacyAppDescriptor struct {
|
||||
AppCode string
|
||||
AppName string
|
||||
LogoURL string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@ -152,6 +153,7 @@ func NewService(store *repository.Store, dashboards *dashboard.DashboardService,
|
||||
type AppInfo struct {
|
||||
AppCode string `json:"app_code"`
|
||||
AppName string `json:"app_name"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
@ -1172,6 +1174,7 @@ func (s *Service) listApps(ctx context.Context) ([]AppInfo, error) {
|
||||
out = append(out, AppInfo{
|
||||
AppCode: appCode,
|
||||
AppName: firstNonEmptyString(app.AppName, appCode),
|
||||
LogoURL: strings.TrimSpace(app.LogoURL),
|
||||
Kind: appKindHyapp,
|
||||
})
|
||||
}
|
||||
@ -1184,6 +1187,7 @@ func (s *Service) listApps(ctx context.Context) ([]AppInfo, error) {
|
||||
out = append(out, AppInfo{
|
||||
AppCode: app.AppCode,
|
||||
AppName: firstNonEmptyString(app.AppName, app.AppCode),
|
||||
LogoURL: strings.TrimSpace(app.LogoURL),
|
||||
Kind: appKindLegacy,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package databi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -8,6 +9,22 @@ import (
|
||||
"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 {
|
||||
return repository.MoneyAccess{
|
||||
UserID: 7,
|
||||
|
||||
@ -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("", 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)
|
||||
}
|
||||
@ -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{
|
||||
"### 财务申请待审核",
|
||||
"- APP:lalu",
|
||||
"- 操作:增加币商金币",
|
||||
"- 目标用户ID:10001",
|
||||
"- 金币数量:1000",
|
||||
"- 充值金额 $ 10.50",
|
||||
"- 申请人:ops(7)",
|
||||
"- 发起时间:2026-07-01 09:02:03",
|
||||
"- 后台地址:https://admin-acc.global-interaction.com/finance/",
|
||||
"- 凭证文字:bank order abc",
|
||||
"- 凭证图片:",
|
||||
"",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 = ¬ified
|
||||
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_amount,recharge_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
|
||||
}
|
||||
@ -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},
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ type coinSellerTarget struct {
|
||||
UserID int64
|
||||
DisplayUserID string
|
||||
CountryID int64
|
||||
CountryCode string
|
||||
RegionID int64
|
||||
}
|
||||
|
||||
@ -125,18 +126,19 @@ func (w *mysqlLegacyCoinSellerRechargeWriter) resolveLegacyUser(ctx context.Cont
|
||||
}
|
||||
matchSQL += ")"
|
||||
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
|
||||
WHERE origin_sys = ? AND `+matchSQL+` AND COALESCE(is_del, 0) = 0
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`, args...)
|
||||
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) {
|
||||
return coinSellerTarget{}, errLegacyCoinSellerNotFound
|
||||
}
|
||||
return coinSellerTarget{}, err
|
||||
}
|
||||
target.CountryCode = strings.ToUpper(strings.TrimSpace(target.CountryCode))
|
||||
if strings.TrimSpace(target.DisplayUserID) == "" {
|
||||
target.DisplayUserID = formatInt64(target.UserID)
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(
|
||||
}, appDB, walletDB)
|
||||
|
||||
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).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(77)))
|
||||
|
||||
@ -108,7 +108,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerByAccountRequiresDealer(
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if err := appMock.ExpectationsWereMet(); err != nil {
|
||||
@ -131,7 +131,7 @@ func TestLegacyCoinSellerRechargeWriterResolveCoinSellerRejectsNonDealer(t *test
|
||||
}, appDB, walletDB)
|
||||
|
||||
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).
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -43,16 +43,29 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store *repository.Store
|
||||
wallet walletclient.Client
|
||||
user userclient.Client
|
||||
now func() time.Time
|
||||
statsRecorder financeCoinSellerRechargeStatsRecorder
|
||||
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
||||
store *repository.Store
|
||||
wallet walletclient.Client
|
||||
user userclient.Client
|
||||
now func() time.Time
|
||||
statsRecorder financeCoinSellerRechargeStatsRecorder
|
||||
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
||||
legacyRegionResolve LegacyCoinSellerRegionResolver
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
keyword := strings.TrimSpace(rawTarget)
|
||||
if keyword == "" {
|
||||
@ -850,6 +887,8 @@ func normalizeRechargeProvider(providerCode string, chain string) (string, strin
|
||||
return "usdt", "TRON", nil
|
||||
case "usdt_bep20", "bep20", "bsc", "bnb":
|
||||
return "usdt", "BSC", nil
|
||||
case "usdt_c2c", "c2c":
|
||||
return "usdt", "C2C", nil
|
||||
default:
|
||||
return "", "", errors.New("充值方式不正确")
|
||||
}
|
||||
@ -865,6 +904,8 @@ func normalizeRechargeChain(chain string) string {
|
||||
return "TRON"
|
||||
case "bsc", "bnb", "bep20", "usdt_bep20":
|
||||
return "BSC"
|
||||
case "c2c", "usdt_c2c":
|
||||
return "C2C"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@ -879,6 +920,8 @@ func receiptProviderCode(order model.CoinSellerRechargeOrder) string {
|
||||
return "usdt_trc20"
|
||||
case "BSC":
|
||||
return "usdt_bep20"
|
||||
case "C2C":
|
||||
return "usdt_c2c"
|
||||
default:
|
||||
return "usdt"
|
||||
}
|
||||
@ -903,6 +946,8 @@ func receiptChain(order model.CoinSellerRechargeOrder) string {
|
||||
return "trc20"
|
||||
case "BSC":
|
||||
return "bep20"
|
||||
case "C2C":
|
||||
return "c2c"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -281,9 +281,16 @@ func TestResolveCoinSellerTargetUsesLegacyWriterForAslan(t *testing.T) {
|
||||
UserID: 10001,
|
||||
DisplayUserID: "agent001",
|
||||
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")
|
||||
if err != nil {
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), 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/reject", middleware.RequireAnyPermission(permissionAuditWithdrawalApplications, permissionAuditFinanceApplications), h.RejectApplication)
|
||||
protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications)
|
||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication)
|
||||
protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication)
|
||||
}
|
||||
|
||||
@ -22,7 +22,6 @@ import (
|
||||
const (
|
||||
permissionViewWithdrawalApplications = "finance-withdrawal:view"
|
||||
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
|
||||
permissionAuditFinanceApplications = "finance-application:audit"
|
||||
|
||||
pointWithdrawalDefaultPointsPerUSD = int64(100000)
|
||||
pointWithdrawalDefaultFeeBPS = int32(500)
|
||||
@ -308,7 +307,7 @@ func withdrawalAuditNotificationEventID(id uint, decision string) string {
|
||||
func canAuditWithdrawal(permissions []string) bool {
|
||||
for _, permission := range permissions {
|
||||
switch strings.TrimSpace(permission) {
|
||||
case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications:
|
||||
case permissionAuditWithdrawalApplications:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,8 @@ type catalogDTO struct {
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
// whitelistEnabled 只表示访问策略是否生效;成员通过独立接口按需加载,避免目录列表膨胀。
|
||||
WhitelistEnabled bool `json:"whitelistEnabled"`
|
||||
}
|
||||
|
||||
type diceConfigDTO struct {
|
||||
@ -215,23 +217,24 @@ func catalogFromProto(item *gamev1.GameCatalogItem) catalogDTO {
|
||||
return catalogDTO{}
|
||||
}
|
||||
return catalogDTO{
|
||||
AppCode: item.GetAppCode(),
|
||||
GameID: item.GetGameId(),
|
||||
PlatformCode: item.GetPlatformCode(),
|
||||
ProviderGameID: item.GetProviderGameId(),
|
||||
GameName: item.GetGameName(),
|
||||
Category: item.GetCategory(),
|
||||
IconURL: item.GetIconUrl(),
|
||||
CoverURL: item.GetCoverUrl(),
|
||||
LaunchMode: item.GetLaunchMode(),
|
||||
Orientation: item.GetOrientation(),
|
||||
SafeHeight: item.GetSafeHeight(),
|
||||
MinCoin: item.GetMinCoin(),
|
||||
Status: item.GetStatus(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
Tags: item.GetTags(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
AppCode: item.GetAppCode(),
|
||||
GameID: item.GetGameId(),
|
||||
PlatformCode: item.GetPlatformCode(),
|
||||
ProviderGameID: item.GetProviderGameId(),
|
||||
GameName: item.GetGameName(),
|
||||
Category: item.GetCategory(),
|
||||
IconURL: item.GetIconUrl(),
|
||||
CoverURL: item.GetCoverUrl(),
|
||||
LaunchMode: item.GetLaunchMode(),
|
||||
Orientation: item.GetOrientation(),
|
||||
SafeHeight: item.GetSafeHeight(),
|
||||
MinCoin: item.GetMinCoin(),
|
||||
Status: item.GetStatus(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
Tags: item.GetTags(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
WhitelistEnabled: item.GetWhitelistEnabled(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
server/admin/internal/modules/gamemanagement/game_whitelist.go
Normal file
221
server/admin/internal/modules/gamemanagement/game_whitelist.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -11,32 +11,39 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
return
|
||||
}
|
||||
|
||||
protected.GET("/admin/game/platforms", middleware.RequirePermission("game:view"), h.ListPlatforms)
|
||||
protected.POST("/admin/game/platforms", middleware.RequirePermission("game:update"), h.CreatePlatform)
|
||||
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequirePermission("game:update"), h.UpdatePlatform)
|
||||
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequirePermission("game:update"), h.SyncPlatformGames)
|
||||
protected.GET("/admin/game/games", middleware.RequirePermission("game:view"), h.ListCatalog)
|
||||
protected.POST("/admin/game/games", middleware.RequirePermission("game:create"), h.CreateCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
|
||||
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteCatalog)
|
||||
protected.GET("/admin/game/self-games", middleware.RequirePermission("game:view"), h.ListSelfGames)
|
||||
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequirePermission("game:update"), h.UpdateDiceConfig)
|
||||
protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:view"), h.GetSelfGameNewUserPolicy)
|
||||
protected.PATCH("/admin/game/self-games/:game_id/new-user-policy", middleware.RequirePermission("game:update"), h.UpdateSelfGameNewUserPolicy)
|
||||
protected.GET("/admin/game/self-games/:game_id/stake-pools", middleware.RequirePermission("game:view"), h.ListSelfGameStakePools)
|
||||
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/robots", middleware.RequirePermission("game:view"), h.ListDiceRobots)
|
||||
protected.POST("/admin/game/robots/generate", middleware.RequirePermission("game:create"), h.GenerateDiceRobots)
|
||||
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequirePermission("game:update"), h.SetDiceRobotStatus)
|
||||
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game:delete", "game:update"), h.DeleteDiceRobot)
|
||||
// 房内猜拳后台沿用游戏管理权限,但路由、DTO 和 game-service RPC 都独立于骰子和独立猜拳,避免配置互相污染。
|
||||
protected.GET("/admin/game/room-rps/config", middleware.RequirePermission("game:view"), h.GetRoomRPSConfig)
|
||||
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/room-rps/challenges/:challenge_id", middleware.RequirePermission("game:view"), h.GetRoomRPSChallenge)
|
||||
// 结算重试和手动过期都是会推进订单状态的运维动作,必须使用 game:update,并由 game-service 再做状态机保护。
|
||||
protected.POST("/admin/game/room-rps/challenges/:challenge_id/retry-settlement", middleware.RequirePermission("game:update"), h.RetryRoomRPSSettlement)
|
||||
protected.POST("/admin/game/room-rps/challenges/:challenge_id/expire", middleware.RequirePermission("game:update"), h.ExpireRoomRPSChallenge)
|
||||
// 游戏列表、自研游戏、机器人和房内猜拳是四个独立授权模块;legacy game:* 只用于兼容存量自定义角色。
|
||||
protected.GET("/admin/game/platforms", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListPlatforms)
|
||||
protected.POST("/admin/game/platforms", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.CreatePlatform)
|
||||
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.UpdatePlatform)
|
||||
protected.POST("/admin/game/platforms/:platform_code/sync-games", middleware.RequireAnyPermission("game-catalog:platform", "game:update"), h.SyncPlatformGames)
|
||||
protected.GET("/admin/game/games", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListCatalog)
|
||||
protected.POST("/admin/game/games", middleware.RequireAnyPermission("game-catalog:create", "game:create"), h.CreateCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.UpdateCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequireAnyPermission("game-catalog:status", "game:status"), h.SetGameStatus)
|
||||
protected.DELETE("/admin/game/games/:game_id", middleware.RequireAnyPermission("game-catalog:delete", "game:delete", "game:update"), h.DeleteCatalog)
|
||||
protected.PATCH("/admin/game/games/:game_id/whitelist", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.SetGameWhitelistEnabled)
|
||||
protected.GET("/admin/game/games/:game_id/whitelist/users", middleware.RequireAnyPermission("game-catalog:view", "game:view"), h.ListGameWhitelistUsers)
|
||||
protected.POST("/admin/game/games/:game_id/whitelist/users", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.AddGameWhitelistUser)
|
||||
protected.DELETE("/admin/game/games/:game_id/whitelist/users/:user_id", middleware.RequireAnyPermission("game-catalog:update", "game:update"), h.DeleteGameWhitelistUser)
|
||||
|
||||
protected.GET("/admin/game/self-games", middleware.RequireAnyPermission("self-game:view", "game:view"), h.ListSelfGames)
|
||||
protected.PATCH("/admin/game/self-games/:game_id/config", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateDiceConfig)
|
||||
protected.GET("/admin/game/self-games/:game_id/new-user-policy", middleware.RequireAnyPermission("self-game:view", "game:view"), h.GetSelfGameNewUserPolicy)
|
||||
protected.PATCH("/admin/game/self-games/:game_id/new-user-policy", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateSelfGameNewUserPolicy)
|
||||
protected.GET("/admin/game/self-games/:game_id/stake-pools", middleware.RequireAnyPermission("self-game:view", "game:view"), h.ListSelfGameStakePools)
|
||||
protected.PATCH("/admin/game/self-games/:game_id/stake-pools/:stake_coin", middleware.RequireAnyPermission("self-game:update", "game:update"), h.UpdateSelfGameStakePool)
|
||||
protected.POST("/admin/game/self-games/:game_id/stake-pools/:stake_coin/adjust", middleware.RequireAnyPermission("self-game:update", "game:update"), h.AdjustSelfGameStakePool)
|
||||
|
||||
protected.GET("/admin/game/robots", middleware.RequireAnyPermission("game-robot:view", "game:view"), h.ListDiceRobots)
|
||||
protected.POST("/admin/game/robots/generate", middleware.RequireAnyPermission("game-robot:create", "game:create"), h.GenerateDiceRobots)
|
||||
protected.PATCH("/admin/game/robots/:user_id/status", middleware.RequireAnyPermission("game-robot:update", "game:update"), h.SetDiceRobotStatus)
|
||||
protected.DELETE("/admin/game/robots/:user_id", middleware.RequireAnyPermission("game-robot:delete", "game:delete", "game:update"), h.DeleteDiceRobot)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ const (
|
||||
adapterVivaGamesV1 = "vivagames_v1"
|
||||
adapterReyouV1 = "reyou_v1"
|
||||
adapterZGameV1 = "zgame_v1"
|
||||
adapterAMGV1 = "amg_v1"
|
||||
adapterYomiV4 = "yomi_v4"
|
||||
defaultGameStatus = "disabled"
|
||||
defaultGameCategory = "casino"
|
||||
@ -141,6 +142,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
|
||||
return fetchReyouGameSyncPlan(platform, req)
|
||||
case adapterZGameV1:
|
||||
return fetchZGameGameSyncPlan(platform, req)
|
||||
case adapterAMGV1:
|
||||
return fetchAMGGameSyncPlan(platform, req)
|
||||
case adapterYomiV4:
|
||||
return fetchYomiGameSyncPlan(platform, req)
|
||||
default:
|
||||
@ -213,6 +216,14 @@ type zgameSyncConfig struct {
|
||||
GameCovers map[string]string `json:"game_covers"`
|
||||
}
|
||||
|
||||
type amgSyncConfig struct {
|
||||
// AMG 文档未提供游戏列表 API;game_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 {
|
||||
// Yomi V4 文档只提供启动鉴权地址,没有游戏列表 API;后台通过 game_names 维护截图里的 gameUid 清单。
|
||||
GameNames map[string]string `json:"game_names"`
|
||||
@ -392,6 +403,18 @@ func fetchZGameGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest)
|
||||
}, 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) {
|
||||
config, err := decodeYomiSyncConfig(platform.GetAdapterConfigJson())
|
||||
if err != nil {
|
||||
@ -429,6 +452,17 @@ func decodeZGameSyncConfig(raw string) (zgameSyncConfig, error) {
|
||||
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) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return yomiSyncConfig{}, nil
|
||||
@ -511,6 +545,42 @@ func zgameCatalogItems(platformCode string, config zgameSyncConfig, req syncGame
|
||||
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) {
|
||||
keys := make([]string, 0, len(config.GameNames)+len(config.GameURLs))
|
||||
seen := make(map[string]struct{}, len(config.GameNames)+len(config.GameURLs))
|
||||
|
||||
@ -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) {
|
||||
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||
PlatformCode: "yomi",
|
||||
|
||||
30
server/admin/internal/modules/giftrecord/dto.go
Normal file
30
server/admin/internal/modules/giftrecord/dto.go
Normal 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"`
|
||||
}
|
||||
87
server/admin/internal/modules/giftrecord/handler.go
Normal file
87
server/admin/internal/modules/giftrecord/handler.go
Normal 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 ""
|
||||
}
|
||||
41
server/admin/internal/modules/giftrecord/request.go
Normal file
41
server/admin/internal/modules/giftrecord/request.go
Normal 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
|
||||
}
|
||||
14
server/admin/internal/modules/giftrecord/routes.go
Normal file
14
server/admin/internal/modules/giftrecord/routes.go
Normal 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)
|
||||
}
|
||||
349
server/admin/internal/modules/giftrecord/service.go
Normal file
349
server/admin/internal/modules/giftrecord/service.go
Normal 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 ""
|
||||
}
|
||||
122
server/admin/internal/modules/giftrecord/service_test.go
Normal file
122
server/admin/internal/modules/giftrecord/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -122,6 +122,22 @@ type tierRequest struct {
|
||||
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 返回用户管理 > 等级配置页面需要的三条等级轨道完整配置。
|
||||
func (h *Handler) ListLevelConfig(c *gin.Context) {
|
||||
track := normalizeTrack(c.Query("track"))
|
||||
@ -340,6 +356,166 @@ func (h *Handler) UpdateTier(c *gin.Context) {
|
||||
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 拥有。
|
||||
func (h *Handler) upsertTier(c *gin.Context, tierID int64) {
|
||||
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) {
|
||||
for _, item := range items {
|
||||
if item.GetTrack() == track {
|
||||
|
||||
@ -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.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/batch", middleware.RequirePermission("level-config:update"), h.BatchUpsertLevelConfig)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -39,8 +39,30 @@ type financeCoinSellerRechargeStats struct {
|
||||
const (
|
||||
financeCoinSellerRechargeMaxPageSize = 100
|
||||
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 {
|
||||
if summary == 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 {
|
||||
return stats, nil
|
||||
}
|
||||
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
|
||||
if h != nil && h.store != nil {
|
||||
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
|
||||
AppCode: appCode,
|
||||
@ -258,6 +281,7 @@ func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCod
|
||||
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
|
||||
return []rechargeBillDTO{}, 0, nil
|
||||
}
|
||||
query.RegionID = h.normalizeFinanceCoinSellerRegionID(ctx, appCode, query.RegionID)
|
||||
page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
|
||||
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
|
||||
items := make([]rechargeBillDTO, 0, prefetch*2)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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 圆整成 ...600;SQL 精确等值前必须先还原成目录精确值。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,6 +240,11 @@ func (s *MongoRechargeBillSource) AppName() string {
|
||||
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 {
|
||||
TotalUSDMinor int64
|
||||
DailyUSDMinor map[string]int64
|
||||
|
||||
@ -16,6 +16,7 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig {
|
||||
Enabled: true,
|
||||
AppCode: "yumi",
|
||||
AppName: "Yumi",
|
||||
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
|
||||
SysOrigin: "LIKEI",
|
||||
MongoDatabase: "test",
|
||||
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 {
|
||||
cfg := yumiBillSourceConfig()
|
||||
cfg.AppCode = "aslan"
|
||||
|
||||
@ -46,6 +46,7 @@ type moneyAppDTO struct {
|
||||
AppID int64 `json:"appId"`
|
||||
AppCode string `json:"appCode"`
|
||||
AppName string `json:"appName"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
PackageName string `json:"packageName"`
|
||||
Platform string `json:"platform"`
|
||||
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) {
|
||||
where, args := scopedAppWhere(access)
|
||||
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
|
||||
WHERE status = 'active'`+where+`
|
||||
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{}
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
items = append(items, item)
|
||||
|
||||
@ -160,6 +160,56 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
|
||||
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 区域目录导出形态;
|
||||
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
|
||||
type RegionCatalogEntry struct {
|
||||
@ -253,8 +303,10 @@ func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfi
|
||||
}
|
||||
}
|
||||
return []moneyAppDTO{{
|
||||
AppCode: appCode,
|
||||
AppName: appName,
|
||||
AppCode: appCode,
|
||||
AppName: appName,
|
||||
// Legacy App 没有 apps 行;其 Logo 与区域源一起配置,避免为了 UI 展示伪造活跃租户。
|
||||
LogoURL: strings.TrimSpace(sourceConfig.LogoURL),
|
||||
Platform: "legacy",
|
||||
Status: "active",
|
||||
}}, regions, countries, nil
|
||||
|
||||
@ -24,6 +24,7 @@ func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
|
||||
cfg := config.MoneyRegionSourceConfig{
|
||||
AppCode: "yumi",
|
||||
AppName: "Yumi",
|
||||
LogoURL: "https://media.example.com/yumi.png",
|
||||
SysOrigin: "LIKEI",
|
||||
}
|
||||
apps, regions, countries, err := legacyRegionDocumentsToMoneyData(cfg, []legacyRegionDocument{
|
||||
@ -35,7 +36,7 @@ func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
args := make([]driver.Value, 0, len(appCodes))
|
||||
for _, appCode := range appCodes {
|
||||
@ -167,12 +168,63 @@ func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) {
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -279,6 +279,12 @@ func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
|
||||
type rechargeBillAppDTO struct {
|
||||
AppCode string `json:"appCode"`
|
||||
AppName string `json:"appName"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
}
|
||||
|
||||
// rechargeBillSourceLogo 是兼容扩展:历史或测试账单源不实现时仍可列出,新的 Mongo 源则补充 Logo。
|
||||
type rechargeBillSourceLogo interface {
|
||||
LogoURL() string
|
||||
}
|
||||
|
||||
// ListRechargeBillApps 返回充值详情可选的 App 目录:hyapp 在册应用 + 配置的 legacy 账单源(Yumi 等)。
|
||||
@ -287,7 +293,7 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
|
||||
seen := map[string]struct{}{}
|
||||
if h.userDB != nil {
|
||||
rows, err := h.userDB.QueryContext(c.Request.Context(), `
|
||||
SELECT app_code, app_name
|
||||
SELECT app_code, app_name, logo_url
|
||||
FROM apps
|
||||
WHERE status = 'active'
|
||||
ORDER BY app_name ASC, app_code ASC`)
|
||||
@ -298,7 +304,7 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
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 列表失败")
|
||||
return
|
||||
}
|
||||
@ -317,7 +323,11 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
|
||||
if _, ok := seen[appCode]; ok {
|
||||
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 })
|
||||
items = append(items, legacyApps...)
|
||||
|
||||
@ -6,31 +6,33 @@ import (
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
|
||||
protected.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)
|
||||
protected.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)
|
||||
protected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
|
||||
protected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
|
||||
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
|
||||
protected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
|
||||
protected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)
|
||||
protected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
|
||||
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
|
||||
protected.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)
|
||||
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)
|
||||
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
|
||||
protected.POST("/admin/payment/third-party-rates/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentRates)
|
||||
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
|
||||
protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct)
|
||||
protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct)
|
||||
protected.DELETE("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:delete"), h.DeleteRechargeProduct)
|
||||
workspaceProtected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
|
||||
workspaceProtected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
|
||||
workspaceProtected.GET("/admin/payment/recharge-bills/overview", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillOverview)
|
||||
// 导出、刷新和配置写操作必须与列表查看解耦,避免只读产品角色获得副作用入口。
|
||||
workspaceProtected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:export"), h.ExportRechargeBills)
|
||||
workspaceProtected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:refresh"), h.RefreshGoogleRechargePaidDetails)
|
||||
workspaceProtected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
|
||||
workspaceProtected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
|
||||
workspaceProtected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
|
||||
workspaceProtected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)
|
||||
workspaceProtected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance)
|
||||
workspaceProtected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
|
||||
workspaceProtected.POST("/admin/payment/temporary-links", middleware.RequirePermission(temporaryPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
|
||||
workspaceProtected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
|
||||
|
||||
appProtected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
|
||||
appProtected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequireAnyPermission("payment-third-party:update-method", "payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
|
||||
appProtected.POST("/admin/payment/third-party-methods/sync", middleware.RequireAnyPermission("payment-third-party:sync-methods", "payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
|
||||
appProtected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequireAnyPermission("payment-third-party:update-rate", "payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
|
||||
appProtected.POST("/admin/payment/third-party-rates/sync", middleware.RequireAnyPermission("payment-third-party:sync-rates", "payment-third-party:update"), h.SyncThirdPartyPaymentRates)
|
||||
appProtected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -34,22 +34,23 @@ func UserDTO(user model.User) map[string]any {
|
||||
teamName := UserTeamName(user)
|
||||
|
||||
return map[string]any{
|
||||
"id": user.ID,
|
||||
"account": user.Username,
|
||||
"username": user.Username,
|
||||
"name": user.Name,
|
||||
"team": teamName,
|
||||
"teamId": user.TeamID,
|
||||
"teamName": teamName,
|
||||
"status": user.Status,
|
||||
"mfa": BoolText(user.MFAEnabled),
|
||||
"mfaEnabled": user.MFAEnabled,
|
||||
"lastLogin": lastLogin,
|
||||
"lastLoginMs": user.LastLoginAtMS,
|
||||
"roles": user.Roles,
|
||||
"role": role,
|
||||
"roleIds": roleIDs,
|
||||
"permissions": PermissionsForUser(user),
|
||||
"id": user.ID,
|
||||
"account": user.Username,
|
||||
"username": user.Username,
|
||||
"name": user.Name,
|
||||
"team": teamName,
|
||||
"teamId": user.TeamID,
|
||||
"teamName": teamName,
|
||||
"status": user.Status,
|
||||
"appScopeMode": user.AppScopeMode,
|
||||
"mfa": BoolText(user.MFAEnabled),
|
||||
"mfaEnabled": user.MFAEnabled,
|
||||
"lastLogin": lastLogin,
|
||||
"lastLoginMs": user.LastLoginAtMS,
|
||||
"roles": user.Roles,
|
||||
"role": role,
|
||||
"roleIds": roleIDs,
|
||||
"permissions": PermissionsForUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
server/admin/internal/repository/app_scope_repository.go
Normal file
130
server/admin/internal/repository/app_scope_repository.go
Normal 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 会把空值回退 lalu;App 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -37,9 +37,12 @@ type LegacyGooglePaidStats struct {
|
||||
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) {
|
||||
where := "app_code = ? AND paid_amount_micro > 0 AND paid_net_micro > 0"
|
||||
where := "app_code = ?"
|
||||
args := []any{strings.ToLower(strings.TrimSpace(appCode))}
|
||||
if startAtMS > 0 {
|
||||
where += " AND bill_created_at_ms >= ?"
|
||||
@ -51,11 +54,14 @@ func (s *Store) LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64,
|
||||
}
|
||||
var stats LegacyGooglePaidStats
|
||||
row := s.db.Raw(`
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(bill_usd_minor), 0),
|
||||
COALESCE(SUM(ROUND(bill_usd_minor * GREATEST(paid_amount_micro - paid_net_micro - paid_tax_micro, 0) / paid_amount_micro)), 0),
|
||||
COALESCE(SUM(ROUND(bill_usd_minor * paid_tax_micro / paid_amount_micro)), 0),
|
||||
COALESCE(SUM(ROUND(bill_usd_minor * paid_net_micro / paid_amount_micro)), 0)
|
||||
SELECT COALESCE(SUM(CASE WHEN synced_at_ms > 0 THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN paid_amount_micro > 0 AND paid_net_micro > 0 THEN bill_usd_minor ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN paid_amount_micro > 0 AND paid_net_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(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
|
||||
WHERE `+where, args...).Row()
|
||||
if err := row.Scan(&stats.SyncedCount, &stats.CoveredUSDMinor, &stats.EstFeeUSDMinor, &stats.EstTaxUSDMinor, &stats.EstNetUSDMinor); err != nil {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -91,6 +91,12 @@ func (s *Store) ListUserMoneyScopes(userID uint) ([]model.UserMoneyScope, error)
|
||||
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 {
|
||||
if userID == 0 {
|
||||
return errors.New("user id is required")
|
||||
|
||||
@ -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()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
|
||||
@ -76,33 +76,48 @@ func (s *Store) SyncDefaultPermissions() error {
|
||||
if err := s.syncDefaultPermissions(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.syncDefaultRolePermissionMigrations(tx)
|
||||
return s.syncManagedDefaultRolePermissions(tx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) syncDefaultRolePermissionMigrations(tx *gorm.DB) error {
|
||||
var platformAdmin model.Role
|
||||
err := tx.Where("code = ?", "platform-admin").First(&platformAdmin).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
func (s *Store) syncManagedDefaultRolePermissions(tx *gorm.DB) error {
|
||||
var allPermissions []model.Permission
|
||||
if err := tx.Find(&allPermissions).Error; err != nil {
|
||||
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
|
||||
err = tx.Where("code = ?", "ops-admin").First(&opsAdmin).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if opsAdmin.ID > 0 {
|
||||
return s.appendRolePermissionsByCode(tx, &opsAdmin, defaultRolePermissionMigrationCodes(opsAdmin.Code))
|
||||
for _, definition := range managedDefaultRoles {
|
||||
var role model.Role
|
||||
result := tx.Where("code = ?", definition.Code).Limit(1).Find(&role)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -84,11 +84,11 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
&model.DataScope{},
|
||||
&model.UserAppScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
&model.LegacyGooglePaidSyncAttempt{},
|
||||
&model.FinanceApplication{},
|
||||
&model.CoinSellerRechargeOrder{},
|
||||
&model.UserWithdrawalApplication{},
|
||||
&model.AdminJob{},
|
||||
|
||||
242
server/admin/internal/repository/role_permission_matrix.go
Normal file
242
server/admin/internal/repository/role_permission_matrix.go
Normal 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
|
||||
}
|
||||
@ -110,7 +110,10 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "子币商申请审核", Code: "coin-seller-sub-application:audit", Kind: "button"},
|
||||
{Name: "金币流水查看", Code: "coin-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: "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:create", Kind: "button"},
|
||||
{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:send", Kind: "button"},
|
||||
{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: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:create", Kind: "button"},
|
||||
// 财务申请权限只描述后台 RBAC 能力:发起人拿 create 和具体操作权限,财务审核人拿 audit;是否能看到审核列表由 audit 权限单独决定。
|
||||
{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:create", 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-withdrawal:view", Kind: "menu"},
|
||||
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", 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: "finance-withdrawal:audit", Kind: "button"},
|
||||
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
|
||||
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
|
||||
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
|
||||
{Name: "内购配置删除", Code: "payment-product:delete", Kind: "button"},
|
||||
{Name: "游戏查看", Code: "game:view", Kind: "menu"},
|
||||
{Name: "游戏创建", Code: "game:create", Kind: "button"},
|
||||
{Name: "游戏更新", Code: "game:update", Kind: "button"},
|
||||
{Name: "游戏状态", Code: "game:status", Kind: "button"},
|
||||
{Name: "游戏删除", Code: "game:delete", Kind: "button"},
|
||||
{Name: "游戏查看", Code: "game:view", Kind: "menu", Description: "兼容旧角色的游戏管理查看权限"},
|
||||
{Name: "游戏创建", Code: "game:create", Kind: "button", Description: "兼容旧角色的游戏管理创建权限"},
|
||||
{Name: "游戏更新", Code: "game:update", Kind: "button", Description: "兼容旧角色的游戏管理更新权限"},
|
||||
{Name: "游戏状态", Code: "game:status", Kind: "button", Description: "兼容旧角色的游戏管理状态权限"},
|
||||
{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:create", 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: "registration-reward:view", Kind: "menu"},
|
||||
{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:update", Kind: "button"},
|
||||
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
||||
@ -169,6 +192,9 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
|
||||
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},
|
||||
{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: "red-packet:view", Kind: "menu"},
|
||||
{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: "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: &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-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},
|
||||
@ -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: "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: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game: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: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, 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: "self-game:view", Sort: 80, 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-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},
|
||||
@ -446,31 +475,34 @@ func (s *Store) seedRoles() error {
|
||||
return err
|
||||
}
|
||||
|
||||
roles := []model.Role{
|
||||
{Name: "平台管理员", Code: "platform-admin", Description: "拥有管理后台全部权限"},
|
||||
{Name: "运维管理员", Code: "ops-admin", Description: "管理用户状态和后台操作"},
|
||||
{Name: "审计员", Code: "auditor", Description: "查看日志和审计记录"},
|
||||
{Name: "只读用户", Code: "readonly", Description: "只读查看后台数据"},
|
||||
}
|
||||
for _, role := range roles {
|
||||
if err := s.db.Where("code = ?", role.Code).FirstOrCreate(&role).Error; err != nil {
|
||||
for _, definition := range managedDefaultRoles {
|
||||
role := model.Role{}
|
||||
result := s.db.Where("code = ?", definition.Code).Limit(1).Find(&role)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
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
|
||||
}
|
||||
if role.Code == "platform-admin" {
|
||||
|
||||
if role.Code == roleCodePlatformAdmin {
|
||||
if err := s.db.Model(&role).Association("Permissions").Replace(permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 预置角色只在没有任何权限时写入默认模板,避免后续人工调整被启动种子覆盖。
|
||||
if s.db.Model(&role).Association("Permissions").Count() == 0 {
|
||||
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 {
|
||||
// 显式 bootstrap 的职责是恢复产品确认的岗位模板;普通启动不会执行 Seed,
|
||||
// 因此这里可以精确替换固定岗位权限,同时不影响用户自行创建的其他角色。
|
||||
if err := s.replaceRolePermissionsByCode(&role, defaultRolePermissionCodes(role.Code)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -495,7 +527,7 @@ func (s *Store) seedUsers(cfg config.Config) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -507,6 +539,8 @@ func (s *Store) seedUsers(cfg config.Config) error {
|
||||
Team: "运营团队",
|
||||
TeamID: &operationsTeamID,
|
||||
Status: model.UserStatusActive,
|
||||
// 全新环境的唯一 bootstrap 管理员必须能进入 App 主站,后续仍可在用户编辑中改成 selected/none。
|
||||
AppScopeMode: model.UserAppScopeModeAll,
|
||||
MFAEnabled: false,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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(¤t, 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
package repository
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
|
||||
permissions := map[string]struct{}{}
|
||||
@ -17,51 +24,228 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
|
||||
for _, code := range []string{
|
||||
"payment-temporary-link:create",
|
||||
"finance:view",
|
||||
"finance-application:create",
|
||||
"finance-application:audit",
|
||||
"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",
|
||||
} {
|
||||
if _, ok := permissions[code]; !ok {
|
||||
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{
|
||||
"payment-temporary-link:create",
|
||||
"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 := 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",
|
||||
} {
|
||||
if _, ok := opsPermissions[code]; !ok {
|
||||
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 {
|
||||
t.Fatal("ops-admin must not receive finance audit permission by default")
|
||||
for _, code := range []string{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,13 +19,13 @@ import (
|
||||
"hyapp-admin-server/internal/modules/dailytask"
|
||||
"hyapp-admin-server/internal/modules/dashboard"
|
||||
"hyapp-admin-server/internal/modules/databi"
|
||||
"hyapp-admin-server/internal/modules/financeapplication"
|
||||
"hyapp-admin-server/internal/modules/financeorder"
|
||||
"hyapp-admin-server/internal/modules/financewithdrawal"
|
||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
"hyapp-admin-server/internal/modules/fullservernotice"
|
||||
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
"hyapp-admin-server/internal/modules/giftdiamond"
|
||||
"hyapp-admin-server/internal/modules/giftrecord"
|
||||
"hyapp-admin-server/internal/modules/health"
|
||||
"hyapp-admin-server/internal/modules/hostagencypolicy"
|
||||
"hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -61,6 +61,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/weeklystar"
|
||||
"hyapp-admin-server/internal/modules/wheel"
|
||||
"hyapp-admin-server/internal/platform/logging"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -84,12 +85,12 @@ type Handlers struct {
|
||||
Dashboard *dashboard.Handler
|
||||
Databi *databi.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
FinanceApplication *financeapplication.Handler
|
||||
FinanceOrder *financeorder.Handler
|
||||
FinanceWithdrawal *financewithdrawal.Handler
|
||||
FullServerNotice *fullservernotice.Handler
|
||||
Game *gamemanagement.Handler
|
||||
GiftDiamond *giftdiamond.Handler
|
||||
GiftRecord *giftrecord.Handler
|
||||
Health *health.Handler
|
||||
HostAgencyPolicy *hostagencypolicy.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
@ -126,7 +127,7 @@ type Handlers struct {
|
||||
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.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")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit))
|
||||
appProtected := protected.Group("")
|
||||
appProtected.Use(middleware.RequireAppScope(store))
|
||||
|
||||
authroutes.RegisterRoutes(api, protected, h.Auth)
|
||||
agencyopening.RegisterRoutes(protected, h.AgencyOpening)
|
||||
achievementconfig.RegisterRoutes(protected, h.AchievementConfig)
|
||||
coinledger.RegisterRoutes(protected, h.CoinLedger)
|
||||
agencyopening.RegisterRoutes(appProtected, h.AgencyOpening)
|
||||
achievementconfig.RegisterRoutes(appProtected, h.AchievementConfig)
|
||||
coinledger.RegisterRoutes(appProtected, h.CoinLedger)
|
||||
menu.RegisterRoutes(protected, h.Menu)
|
||||
rbac.RegisterRoutes(protected, h.RBAC)
|
||||
redpacket.RegisterRoutes(protected, h.RedPacket)
|
||||
reportmodule.RegisterRoutes(protected, h.Report)
|
||||
resourcemodule.RegisterRoutes(protected, h.Resource)
|
||||
redpacket.RegisterRoutes(appProtected, h.RedPacket)
|
||||
reportmodule.RegisterRoutes(appProtected, h.Report)
|
||||
resourcemodule.RegisterRoutes(appProtected, h.Resource)
|
||||
adminuser.RegisterRoutes(protected, h.AdminUser)
|
||||
appuser.RegisterRoutes(protected, h.AppUser)
|
||||
appuser.RegisterRoutes(appProtected, h.AppUser)
|
||||
appregistry.RegisterRoutes(protected, h.AppRegistry)
|
||||
appconfig.RegisterRoutes(protected, h.AppConfig)
|
||||
countryregion.RegisterRoutes(protected, h.CountryRegion)
|
||||
cprelation.RegisterRoutes(protected, h.CPRelation)
|
||||
cpweeklyrank.RegisterRoutes(protected, h.CPWeeklyRank)
|
||||
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
|
||||
dailytask.RegisterRoutes(protected, h.DailyTask)
|
||||
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
||||
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
|
||||
appconfig.RegisterRoutes(appProtected, h.AppConfig)
|
||||
countryregion.RegisterRoutes(appProtected, h.CountryRegion)
|
||||
cprelation.RegisterRoutes(appProtected, h.CPRelation)
|
||||
cpweeklyrank.RegisterRoutes(appProtected, h.CPWeeklyRank)
|
||||
cumulativerechargereward.RegisterRoutes(appProtected, h.CumulativeRecharge)
|
||||
dailytask.RegisterRoutes(appProtected, h.DailyTask)
|
||||
firstrechargereward.RegisterRoutes(appProtected, h.FirstRechargeReward)
|
||||
financeorder.RegisterRoutes(protected, h.FinanceOrder)
|
||||
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
|
||||
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
|
||||
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
||||
regionblock.RegisterRoutes(protected, h.RegionBlock)
|
||||
riskconfig.RegisterRoutes(protected, h.RiskConfig)
|
||||
gamemanagement.RegisterRoutes(protected, h.Game)
|
||||
giftdiamond.RegisterRoutes(protected, h.GiftDiamond)
|
||||
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
||||
roomrocket.RegisterRoutes(protected, h.RoomRocket)
|
||||
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
|
||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||
fullservernotice.RegisterRoutes(appProtected, h.FullServerNotice)
|
||||
registrationreward.RegisterRoutes(appProtected, h.RegistrationReward)
|
||||
regionblock.RegisterRoutes(appProtected, h.RegionBlock)
|
||||
riskconfig.RegisterRoutes(appProtected, h.RiskConfig)
|
||||
gamemanagement.RegisterRoutes(appProtected, h.Game)
|
||||
giftdiamond.RegisterRoutes(appProtected, h.GiftDiamond)
|
||||
giftrecord.RegisterRoutes(appProtected, h.GiftRecord)
|
||||
roomadmin.RegisterRoutes(appProtected, h.RoomAdmin)
|
||||
roomrocket.RegisterRoutes(appProtected, h.RoomRocket)
|
||||
roomturnoverreward.RegisterRoutes(appProtected, h.RoomTurnoverReward)
|
||||
dashboard.RegisterRoutes(appProtected, protected, h.Dashboard)
|
||||
databi.RegisterRoutes(protected, h.Databi)
|
||||
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
||||
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||
hostwithdrawal.RegisterRoutes(protected, h.HostWithdrawal)
|
||||
inviteactivityreward.RegisterRoutes(protected, h.InviteActivityReward)
|
||||
hostagencypolicy.RegisterRoutes(appProtected, h.HostAgencyPolicy)
|
||||
hostsalarysettlement.RegisterRoutes(appProtected, h.HostSalarySettlement)
|
||||
hostorg.RegisterRoutes(appProtected, h.HostOrg)
|
||||
hostwithdrawal.RegisterRoutes(appProtected, h.HostWithdrawal)
|
||||
inviteactivityreward.RegisterRoutes(appProtected, h.InviteActivityReward)
|
||||
audit.RegisterRoutes(protected, h.Audit)
|
||||
payment.RegisterRoutes(protected, h.Payment)
|
||||
policyconfig.RegisterRoutes(protected, h.PolicyConfig)
|
||||
pointwithdrawalconfig.RegisterRoutes(protected, h.PointWithdrawalConfig)
|
||||
prettyid.RegisterRoutes(protected, h.PrettyID)
|
||||
payment.RegisterRoutes(protected, appProtected, h.Payment)
|
||||
policyconfig.RegisterRoutes(appProtected, h.PolicyConfig)
|
||||
pointwithdrawalconfig.RegisterRoutes(appProtected, h.PointWithdrawalConfig)
|
||||
prettyid.RegisterRoutes(appProtected, h.PrettyID)
|
||||
job.RegisterRoutes(protected, h.Job)
|
||||
levelconfig.RegisterRoutes(protected, h.LevelConfig)
|
||||
opscenter.RegisterRoutes(protected, h.OpsCenter)
|
||||
upload.RegisterRoutes(protected, h.Upload)
|
||||
levelconfig.RegisterRoutes(appProtected, h.LevelConfig)
|
||||
opscenter.RegisterRoutes(appProtected, h.OpsCenter)
|
||||
upload.RegisterRoutes(appProtected, h.Upload)
|
||||
search.RegisterRoutes(protected, h.Search)
|
||||
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
|
||||
sevendaycheckin.RegisterRoutes(appProtected, h.SevenDayCheckIn)
|
||||
team.RegisterRoutes(protected, h.Team)
|
||||
teamsalarypolicy.RegisterRoutes(protected, h.TeamSalaryPolicy)
|
||||
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
|
||||
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
|
||||
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
||||
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
|
||||
wheel.RegisterRoutes(protected, h.Wheel)
|
||||
teamsalarypolicy.RegisterRoutes(appProtected, h.TeamSalaryPolicy)
|
||||
teamsalarysettlement.RegisterRoutes(appProtected, h.TeamSalarySettlement)
|
||||
userleaderboard.RegisterRoutes(appProtected, h.UserLeaderboard)
|
||||
vipconfig.RegisterRoutes(appProtected, h.VIPConfig)
|
||||
weeklystar.RegisterRoutes(appProtected, h.WeeklyStar)
|
||||
wheel.RegisterRoutes(appProtected, h.Wheel)
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
39
server/admin/migrations/092_remove_finance_applications.sql
Normal file
39
server/admin/migrations/092_remove_finance_applications.sql
Normal 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'
|
||||
);
|
||||
247
server/admin/migrations/093_role_permission_matrix.sql
Normal file
247
server/admin/migrations/093_role_permission_matrix.sql
Normal file
@ -0,0 +1,247 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 七类岗位及权限矩阵来自产品确认的《身份权限预览》:固定岗位使用精确模板,不能再通过旧 ops-admin 迁移不断追加跨模块权限。
|
||||
-- 性能边界:本迁移只扫描 7 个 role code 和数百条 permission code;role.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'
|
||||
);
|
||||
21
server/admin/migrations/094_admin_user_app_scope.sql
Normal file
21
server/admin/migrations/094_admin_user_app_scope.sql
Normal 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
Loading…
x
Reference in New Issue
Block a user