Compare commits

...

33 Commits

Author SHA1 Message Date
zhx
152bdf232e 权限相关 2026-06-15 10:44:18 +08:00
zhx
7744232add 币商转账 2026-06-15 01:29:06 +08:00
zhx
da1c5e7c30 工资相关 2026-06-15 00:37:52 +08:00
zhx
18efb4ed5d 主播相关 2026-06-14 23:42:39 +08:00
zhx
09d5747cbd 钱包 2026-06-13 14:34:49 +08:00
zhx
93e5cd0218 fix: generate lucky gift tier ids in admin 2026-06-12 23:28:04 +08:00
zhx
982ac46415 fix: validate lucky gift tier ids 2026-06-12 23:22:45 +08:00
zhx
3b491ffb26 公共逻辑抽离 2026-06-12 22:40:39 +08:00
zhx
f9e011b9cb 口径 2026-06-12 20:37:23 +08:00
zhx
75b867ca16 Merge branch 'main' of gitea.haiyihy.com:hy/hyapp-server 2026-06-12 20:12:49 +08:00
zhx
2556316852 增加黑色必胜 2026-06-12 20:12:47 +08:00
zhx
4024f62e53 fix: proxy reyou callbacks with chatapp3 token 2026-06-12 20:09:17 +08:00
zhx
13240f4880 Merge branch 'test' into main 2026-06-12 19:55:55 +08:00
zhx
dffca4dec7 机器人策略 2026-06-12 19:40:21 +08:00
zhx
b810bae232 feat: add role invitation confirmation flow 2026-06-12 18:08:53 +08:00
zhx
4b5c2931ea fix(admin): resolve pretty id grant target aliases 2026-06-12 15:50:22 +08:00
zhx
9df03674d1 相关接口 2026-06-12 12:08:01 +08:00
zhx
cee48232f8 update self game catalog handling 2026-06-12 03:08:18 +08:00
zhx
1b8241c3d2 Allow zero coin game outbox events 2026-06-12 01:34:39 +08:00
zhx
d2dbc88e5f Use likei profiles for game robots 2026-06-12 01:21:33 +08:00
zhx
84bba92611 improve self game robot matching 2026-06-12 00:35:14 +08:00
zhx
2e6ff83aec fix user country change outbox 2026-06-11 23:47:08 +08:00
zhx
949801aebb 1 2026-06-11 22:24:16 +08:00
zhx
aa913c8332 1 2026-06-11 21:52:50 +08:00
zhx
25a3855454 通用机器人 2026-06-11 21:34:13 +08:00
zhx
ff0e42e3d3 Merge branch 'test' of gitea.haiyihy.com:hy/hyapp-server
# Conflicts:
#	scripts/mysql/038_split_bd_leader_profiles.sql
#	server/admin/internal/modules/hostorg/reader.go
#	server/admin/internal/modules/hostorg/service_test.go
#	server/admin/internal/modules/teamsalarysettlement/service.go
#	server/admin/internal/modules/teamsalarysettlement/service_integration_test.go
#	services/gateway-service/internal/transport/http/gameapi/game_handler.go
#	services/gateway-service/internal/transport/http/gameapi/handler.go
#	services/gateway-service/internal/transport/http/httproutes/router.go
#	services/user-service/deploy/mysql/initdb/001_user_service.sql
#	services/user-service/internal/storage/mysql/host/admin.go
#	services/user-service/internal/storage/mysql/host/common.go
#	services/user-service/internal/testutil/mysqltest/mysqltest.go
2026-06-11 19:45:31 +08:00
zhx
e50d273ae2 Disable manager resource grants by default 2026-06-10 19:29:07 +08:00
zhx
919364d4d0 feat: add hotgame callback compatibility proxy 2026-06-10 18:36:47 +08:00
zhx
27ab1317d2 fix admin bd leader status route 2026-06-10 15:39:47 +08:00
zhx
0dea7c9b43 拆分 BD 与 BD Leader 身份模型 2026-06-10 11:08:46 +08:00
zhx
f172ed8178 Add reyou game integration and weekly star enrichment 2026-06-09 11:27:31 +08:00
zhx
86ec305448 Merge remote-tracking branch 'origin/test'
# Conflicts:
#	services/gateway-service/internal/transport/http/response_test.go
2026-06-08 21:46:41 +08:00
zhx
3ed3f85837 fix: enrich weekly star reward groups 2026-06-08 19:22:58 +08:00
206 changed files with 23231 additions and 3146 deletions

View File

@ -1,8 +1,8 @@
COMPOSE_SERVICES := mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
GO_RUN_SERVICES := gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice stats statistics mq namesrv broker mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service
COMPOSE_SERVICES := mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service robot-service game-service notice-service statistics-service
GO_RUN_SERVICES := gateway-service room-service wallet-service user-service activity-service cron-service robot-service game-service notice-service statistics-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron robot robots rb game games ns notice stats statistics mq namesrv broker mysql redis rocketmq-namesrv rocketmq-broker gateway-service room-service wallet-service user-service activity-service cron-service robot-service game-service notice-service statistics-service
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(if $(filter stats statistics,$(SERVICE_TOKEN)),statistics-service,$(if $(filter namesrv,$(SERVICE_TOKEN)),rocketmq-namesrv,$(if $(filter mq broker,$(SERVICE_TOKEN)),rocketmq-broker,$(SERVICE_TOKEN))))))))))))))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter robot robots rb,$(SERVICE_TOKEN)),robot-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(if $(filter stats statistics,$(SERVICE_TOKEN)),statistics-service,$(if $(filter namesrv,$(SERVICE_TOKEN)),rocketmq-namesrv,$(if $(filter mq broker,$(SERVICE_TOKEN)),rocketmq-broker,$(SERVICE_TOKEN)))))))))))))))
ADMIN_CONFIG ?= configs/config.yaml
DEV_RUN_SERVICES ?= all
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter mysql redis,$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
@ -23,6 +23,7 @@ proto:
proto/activity/v1/activity.proto \
proto/room/v1/room.proto \
proto/game/v1/game.proto \
proto/robot/v1/robot.proto \
proto/wallet/v1/wallet.proto \
proto/events/room/v1/events.proto
@ -91,7 +92,7 @@ up:
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service statistics-service; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service robot-service game-service notice-service statistics-service; \
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
docker compose up -d $(SERVICE_ID); \
else \

File diff suppressed because it is too large Load Diff

View File

@ -969,6 +969,39 @@ message ListInviteActivityRewardClaimsResponse {
int64 total = 2;
}
// InviteActivityRewardInviteeSummary
message InviteActivityRewardInviteeSummary {
int64 invited_user_id = 1;
int64 recharge_coin_amount = 2;
int64 invited_at_ms = 3;
}
// InviteActivityRewardSummary
message InviteActivityRewardSummary {
string app_code = 1;
string cycle_key = 2;
int64 user_id = 3;
int64 invite_count = 4;
int64 valid_invite_count = 5;
int64 total_recharge_coin_amount = 6;
int64 total_reward_coin_amount = 7;
int64 updated_at_ms = 8;
repeated InviteActivityRewardInviteeSummary invitees = 9;
}
message ListInviteActivityRewardSummariesRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string cycle_key = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListInviteActivityRewardSummariesResponse {
repeated InviteActivityRewardSummary summaries = 1;
int64 total = 2;
}
// RoomTurnoverRewardTier threshold_coin_spent
message RoomTurnoverRewardTier {
int64 tier_id = 1;
@ -2151,6 +2184,7 @@ service AdminInviteActivityRewardService {
rpc GetInviteActivityRewardConfig(GetInviteActivityRewardConfigRequest) returns (GetInviteActivityRewardConfigResponse);
rpc UpdateInviteActivityRewardConfig(UpdateInviteActivityRewardConfigRequest) returns (UpdateInviteActivityRewardConfigResponse);
rpc ListInviteActivityRewardClaims(ListInviteActivityRewardClaimsRequest) returns (ListInviteActivityRewardClaimsResponse);
rpc ListInviteActivityRewardSummaries(ListInviteActivityRewardSummariesRequest) returns (ListInviteActivityRewardSummariesResponse);
}
// AdminRoomTurnoverRewardService 访

View File

@ -3638,9 +3638,10 @@ var AdminCumulativeRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
}
const (
AdminInviteActivityRewardService_GetInviteActivityRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/GetInviteActivityRewardConfig"
AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/UpdateInviteActivityRewardConfig"
AdminInviteActivityRewardService_ListInviteActivityRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/ListInviteActivityRewardClaims"
AdminInviteActivityRewardService_GetInviteActivityRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/GetInviteActivityRewardConfig"
AdminInviteActivityRewardService_UpdateInviteActivityRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/UpdateInviteActivityRewardConfig"
AdminInviteActivityRewardService_ListInviteActivityRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/ListInviteActivityRewardClaims"
AdminInviteActivityRewardService_ListInviteActivityRewardSummaries_FullMethodName = "/hyapp.activity.v1.AdminInviteActivityRewardService/ListInviteActivityRewardSummaries"
)
// AdminInviteActivityRewardServiceClient is the client API for AdminInviteActivityRewardService service.
@ -3652,6 +3653,7 @@ type AdminInviteActivityRewardServiceClient interface {
GetInviteActivityRewardConfig(ctx context.Context, in *GetInviteActivityRewardConfigRequest, opts ...grpc.CallOption) (*GetInviteActivityRewardConfigResponse, error)
UpdateInviteActivityRewardConfig(ctx context.Context, in *UpdateInviteActivityRewardConfigRequest, opts ...grpc.CallOption) (*UpdateInviteActivityRewardConfigResponse, error)
ListInviteActivityRewardClaims(ctx context.Context, in *ListInviteActivityRewardClaimsRequest, opts ...grpc.CallOption) (*ListInviteActivityRewardClaimsResponse, error)
ListInviteActivityRewardSummaries(ctx context.Context, in *ListInviteActivityRewardSummariesRequest, opts ...grpc.CallOption) (*ListInviteActivityRewardSummariesResponse, error)
}
type adminInviteActivityRewardServiceClient struct {
@ -3692,6 +3694,16 @@ func (c *adminInviteActivityRewardServiceClient) ListInviteActivityRewardClaims(
return out, nil
}
func (c *adminInviteActivityRewardServiceClient) ListInviteActivityRewardSummaries(ctx context.Context, in *ListInviteActivityRewardSummariesRequest, opts ...grpc.CallOption) (*ListInviteActivityRewardSummariesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListInviteActivityRewardSummariesResponse)
err := c.cc.Invoke(ctx, AdminInviteActivityRewardService_ListInviteActivityRewardSummaries_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminInviteActivityRewardServiceServer is the server API for AdminInviteActivityRewardService service.
// All implementations must embed UnimplementedAdminInviteActivityRewardServiceServer
// for forward compatibility.
@ -3701,6 +3713,7 @@ type AdminInviteActivityRewardServiceServer interface {
GetInviteActivityRewardConfig(context.Context, *GetInviteActivityRewardConfigRequest) (*GetInviteActivityRewardConfigResponse, error)
UpdateInviteActivityRewardConfig(context.Context, *UpdateInviteActivityRewardConfigRequest) (*UpdateInviteActivityRewardConfigResponse, error)
ListInviteActivityRewardClaims(context.Context, *ListInviteActivityRewardClaimsRequest) (*ListInviteActivityRewardClaimsResponse, error)
ListInviteActivityRewardSummaries(context.Context, *ListInviteActivityRewardSummariesRequest) (*ListInviteActivityRewardSummariesResponse, error)
mustEmbedUnimplementedAdminInviteActivityRewardServiceServer()
}
@ -3720,6 +3733,9 @@ func (UnimplementedAdminInviteActivityRewardServiceServer) UpdateInviteActivityR
func (UnimplementedAdminInviteActivityRewardServiceServer) ListInviteActivityRewardClaims(context.Context, *ListInviteActivityRewardClaimsRequest) (*ListInviteActivityRewardClaimsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListInviteActivityRewardClaims not implemented")
}
func (UnimplementedAdminInviteActivityRewardServiceServer) ListInviteActivityRewardSummaries(context.Context, *ListInviteActivityRewardSummariesRequest) (*ListInviteActivityRewardSummariesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListInviteActivityRewardSummaries not implemented")
}
func (UnimplementedAdminInviteActivityRewardServiceServer) mustEmbedUnimplementedAdminInviteActivityRewardServiceServer() {
}
func (UnimplementedAdminInviteActivityRewardServiceServer) testEmbeddedByValue() {}
@ -3796,6 +3812,24 @@ func _AdminInviteActivityRewardService_ListInviteActivityRewardClaims_Handler(sr
return interceptor(ctx, in, info, handler)
}
func _AdminInviteActivityRewardService_ListInviteActivityRewardSummaries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListInviteActivityRewardSummariesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminInviteActivityRewardServiceServer).ListInviteActivityRewardSummaries(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminInviteActivityRewardService_ListInviteActivityRewardSummaries_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminInviteActivityRewardServiceServer).ListInviteActivityRewardSummaries(ctx, req.(*ListInviteActivityRewardSummariesRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminInviteActivityRewardService_ServiceDesc is the grpc.ServiceDesc for AdminInviteActivityRewardService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -3815,6 +3849,10 @@ var AdminInviteActivityRewardService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListInviteActivityRewardClaims",
Handler: _AdminInviteActivityRewardService_ListInviteActivityRewardClaims_Handler,
},
{
MethodName: "ListInviteActivityRewardSummaries",
Handler: _AdminInviteActivityRewardService_ListInviteActivityRewardSummaries_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

File diff suppressed because it is too large Load Diff

View File

@ -203,6 +203,8 @@ message DiceStakeOption {
}
message DiceConfig {
reserved 14;
string app_code = 1;
string game_id = 2;
string status = 3;
@ -235,6 +237,7 @@ message RoomRPSStakeGift {
int64 gift_price_coin = 4;
bool enabled = 5;
int32 sort_order = 6;
string gift_id_text = 7;
}
message RoomRPSConfig {
@ -275,6 +278,7 @@ message RoomRPSChallenge {
int64 matched_at_ms = 16;
int64 settled_at_ms = 17;
int64 updated_at_ms = 18;
string stake_gift_id_text = 19;
}
message GetRoomRPSConfigRequest {
@ -293,6 +297,7 @@ message CreateRoomRPSChallengeRequest {
int64 region_id = 4;
int64 gift_id = 5;
string gesture = 6;
string gift_id_text = 7;
}
message AcceptRoomRPSChallengeRequest {
@ -509,6 +514,81 @@ message UpdateDiceConfigRequest {
DiceConfig config = 2;
}
message SelfGameNewUserPolicy {
string app_code = 1;
string game_id = 2;
bool enabled = 3;
int32 protection_rounds = 4;
int32 protection_hours = 5;
int64 max_stake_coin = 6;
int64 lifetime_subsidy_quota_coin = 7;
int64 day1_subsidy_quota_coin = 8;
int64 single_round_subsidy_cap_coin = 9;
string strategy_level = 10;
bool lose_streak_protection_enabled = 11;
int32 lose_streak_trigger = 12;
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
int32 normal_phase_target_win_rate_percent = 15;
bool black_pool_robot_force_win_enabled = 16;
}
message GetSelfGameNewUserPolicyRequest {
RequestMeta meta = 1;
string game_id = 2;
}
message UpdateSelfGameNewUserPolicyRequest {
RequestMeta meta = 1;
SelfGameNewUserPolicy policy = 2;
}
message SelfGameNewUserPolicyResponse {
SelfGameNewUserPolicy policy = 1;
int64 server_time_ms = 2;
}
message SelfGameStakePool {
string app_code = 1;
string game_id = 2;
int64 stake_coin = 3;
int64 balance_coin = 4;
int64 reserved_coin = 5;
int64 available_coin = 6;
int64 target_balance_coin = 7;
int64 safe_balance_coin = 8;
int64 warning_balance_coin = 9;
int64 danger_balance_coin = 10;
string status = 11;
string pool_level = 12;
int64 required_pool_coin = 13;
int64 human_win_capacity = 14;
bool bot_match_enabled = 15;
string black_reason = 16;
int64 created_at_ms = 17;
int64 updated_at_ms = 18;
}
message ListSelfGameStakePoolsRequest {
RequestMeta meta = 1;
string game_id = 2;
}
message UpdateSelfGameStakePoolRequest {
RequestMeta meta = 1;
SelfGameStakePool pool = 2;
}
message SelfGameStakePoolResponse {
SelfGameStakePool pool = 1;
int64 server_time_ms = 2;
}
message ListSelfGameStakePoolsResponse {
repeated SelfGameStakePool pools = 1;
int64 server_time_ms = 2;
}
message DicePoolAdjustment {
string adjustment_id = 1;
string game_id = 2;
@ -517,17 +597,19 @@ message DicePoolAdjustment {
string reason = 5;
int64 balance_after = 6;
int64 created_at_ms = 7;
int64 stake_coin = 8;
}
message AdjustDicePoolRequest {
message AdjustSelfGameStakePoolRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 amount_coin = 3;
string direction = 4;
string reason = 5;
int64 stake_coin = 6;
}
message AdjustDicePoolResponse {
message AdjustSelfGameStakePoolResponse {
DicePoolAdjustment adjustment = 1;
DiceConfig config = 2;
int64 server_time_ms = 3;
@ -629,7 +711,11 @@ service GameAdminService {
rpc DeleteCatalog(DeleteCatalogRequest) returns (DeleteCatalogResponse);
rpc ListSelfGames(ListSelfGamesRequest) returns (ListSelfGamesResponse);
rpc UpdateDiceConfig(UpdateDiceConfigRequest) returns (DiceConfigResponse);
rpc AdjustDicePool(AdjustDicePoolRequest) returns (AdjustDicePoolResponse);
rpc GetSelfGameNewUserPolicy(GetSelfGameNewUserPolicyRequest) returns (SelfGameNewUserPolicyResponse);
rpc UpdateSelfGameNewUserPolicy(UpdateSelfGameNewUserPolicyRequest) returns (SelfGameNewUserPolicyResponse);
rpc ListSelfGameStakePools(ListSelfGameStakePoolsRequest) returns (ListSelfGameStakePoolsResponse);
rpc UpdateSelfGameStakePool(UpdateSelfGameStakePoolRequest) returns (SelfGameStakePoolResponse);
rpc AdjustSelfGameStakePool(AdjustSelfGameStakePoolRequest) returns (AdjustSelfGameStakePoolResponse);
rpc ListDiceRobots(ListDiceRobotsRequest) returns (ListDiceRobotsResponse);
rpc RegisterDiceRobots(RegisterDiceRobotsRequest) returns (RegisterDiceRobotsResponse);
rpc SetDiceRobotStatus(SetDiceRobotStatusRequest) returns (DiceRobotResponse);

View File

@ -937,25 +937,29 @@ var GameCronService_ServiceDesc = grpc.ServiceDesc{
}
const (
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
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_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
GameAdminService_AdjustDicePool_FullMethodName = "/hyapp.game.v1.GameAdminService/AdjustDicePool"
GameAdminService_ListDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/ListDiceRobots"
GameAdminService_RegisterDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/RegisterDiceRobots"
GameAdminService_SetDiceRobotStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetDiceRobotStatus"
GameAdminService_DeleteDiceRobot_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteDiceRobot"
GameAdminService_GetRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/GetRoomRPSConfig"
GameAdminService_UpdateRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateRoomRPSConfig"
GameAdminService_ListRoomRPSChallenges_FullMethodName = "/hyapp.game.v1.GameAdminService/ListRoomRPSChallenges"
GameAdminService_GetRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAdminService/GetRoomRPSChallenge"
GameAdminService_RetryRoomRPSSettlement_FullMethodName = "/hyapp.game.v1.GameAdminService/RetryRoomRPSSettlement"
GameAdminService_ExpireRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAdminService/ExpireRoomRPSChallenge"
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
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_ListSelfGames_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGames"
GameAdminService_UpdateDiceConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateDiceConfig"
GameAdminService_GetSelfGameNewUserPolicy_FullMethodName = "/hyapp.game.v1.GameAdminService/GetSelfGameNewUserPolicy"
GameAdminService_UpdateSelfGameNewUserPolicy_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateSelfGameNewUserPolicy"
GameAdminService_ListSelfGameStakePools_FullMethodName = "/hyapp.game.v1.GameAdminService/ListSelfGameStakePools"
GameAdminService_UpdateSelfGameStakePool_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateSelfGameStakePool"
GameAdminService_AdjustSelfGameStakePool_FullMethodName = "/hyapp.game.v1.GameAdminService/AdjustSelfGameStakePool"
GameAdminService_ListDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/ListDiceRobots"
GameAdminService_RegisterDiceRobots_FullMethodName = "/hyapp.game.v1.GameAdminService/RegisterDiceRobots"
GameAdminService_SetDiceRobotStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetDiceRobotStatus"
GameAdminService_DeleteDiceRobot_FullMethodName = "/hyapp.game.v1.GameAdminService/DeleteDiceRobot"
GameAdminService_GetRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/GetRoomRPSConfig"
GameAdminService_UpdateRoomRPSConfig_FullMethodName = "/hyapp.game.v1.GameAdminService/UpdateRoomRPSConfig"
GameAdminService_ListRoomRPSChallenges_FullMethodName = "/hyapp.game.v1.GameAdminService/ListRoomRPSChallenges"
GameAdminService_GetRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAdminService/GetRoomRPSChallenge"
GameAdminService_RetryRoomRPSSettlement_FullMethodName = "/hyapp.game.v1.GameAdminService/RetryRoomRPSSettlement"
GameAdminService_ExpireRoomRPSChallenge_FullMethodName = "/hyapp.game.v1.GameAdminService/ExpireRoomRPSChallenge"
)
// GameAdminServiceClient is the client API for GameAdminService service.
@ -970,7 +974,11 @@ type GameAdminServiceClient interface {
DeleteCatalog(ctx context.Context, in *DeleteCatalogRequest, opts ...grpc.CallOption) (*DeleteCatalogResponse, error)
ListSelfGames(ctx context.Context, in *ListSelfGamesRequest, opts ...grpc.CallOption) (*ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, in *UpdateDiceConfigRequest, opts ...grpc.CallOption) (*DiceConfigResponse, error)
AdjustDicePool(ctx context.Context, in *AdjustDicePoolRequest, opts ...grpc.CallOption) (*AdjustDicePoolResponse, error)
GetSelfGameNewUserPolicy(ctx context.Context, in *GetSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error)
UpdateSelfGameNewUserPolicy(ctx context.Context, in *UpdateSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error)
ListSelfGameStakePools(ctx context.Context, in *ListSelfGameStakePoolsRequest, opts ...grpc.CallOption) (*ListSelfGameStakePoolsResponse, error)
UpdateSelfGameStakePool(ctx context.Context, in *UpdateSelfGameStakePoolRequest, opts ...grpc.CallOption) (*SelfGameStakePoolResponse, error)
AdjustSelfGameStakePool(ctx context.Context, in *AdjustSelfGameStakePoolRequest, opts ...grpc.CallOption) (*AdjustSelfGameStakePoolResponse, error)
ListDiceRobots(ctx context.Context, in *ListDiceRobotsRequest, opts ...grpc.CallOption) (*ListDiceRobotsResponse, error)
RegisterDiceRobots(ctx context.Context, in *RegisterDiceRobotsRequest, opts ...grpc.CallOption) (*RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(ctx context.Context, in *SetDiceRobotStatusRequest, opts ...grpc.CallOption) (*DiceRobotResponse, error)
@ -1071,10 +1079,50 @@ func (c *gameAdminServiceClient) UpdateDiceConfig(ctx context.Context, in *Updat
return out, nil
}
func (c *gameAdminServiceClient) AdjustDicePool(ctx context.Context, in *AdjustDicePoolRequest, opts ...grpc.CallOption) (*AdjustDicePoolResponse, error) {
func (c *gameAdminServiceClient) GetSelfGameNewUserPolicy(ctx context.Context, in *GetSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdjustDicePoolResponse)
err := c.cc.Invoke(ctx, GameAdminService_AdjustDicePool_FullMethodName, in, out, cOpts...)
out := new(SelfGameNewUserPolicyResponse)
err := c.cc.Invoke(ctx, GameAdminService_GetSelfGameNewUserPolicy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) UpdateSelfGameNewUserPolicy(ctx context.Context, in *UpdateSelfGameNewUserPolicyRequest, opts ...grpc.CallOption) (*SelfGameNewUserPolicyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SelfGameNewUserPolicyResponse)
err := c.cc.Invoke(ctx, GameAdminService_UpdateSelfGameNewUserPolicy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListSelfGameStakePools(ctx context.Context, in *ListSelfGameStakePoolsRequest, opts ...grpc.CallOption) (*ListSelfGameStakePoolsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSelfGameStakePoolsResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListSelfGameStakePools_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) UpdateSelfGameStakePool(ctx context.Context, in *UpdateSelfGameStakePoolRequest, opts ...grpc.CallOption) (*SelfGameStakePoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SelfGameStakePoolResponse)
err := c.cc.Invoke(ctx, GameAdminService_UpdateSelfGameStakePool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) AdjustSelfGameStakePool(ctx context.Context, in *AdjustSelfGameStakePoolRequest, opts ...grpc.CallOption) (*AdjustSelfGameStakePoolResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdjustSelfGameStakePoolResponse)
err := c.cc.Invoke(ctx, GameAdminService_AdjustSelfGameStakePool_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
@ -1193,7 +1241,11 @@ type GameAdminServiceServer interface {
DeleteCatalog(context.Context, *DeleteCatalogRequest) (*DeleteCatalogResponse, error)
ListSelfGames(context.Context, *ListSelfGamesRequest) (*ListSelfGamesResponse, error)
UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error)
AdjustDicePool(context.Context, *AdjustDicePoolRequest) (*AdjustDicePoolResponse, error)
GetSelfGameNewUserPolicy(context.Context, *GetSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error)
UpdateSelfGameNewUserPolicy(context.Context, *UpdateSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error)
ListSelfGameStakePools(context.Context, *ListSelfGameStakePoolsRequest) (*ListSelfGameStakePoolsResponse, error)
UpdateSelfGameStakePool(context.Context, *UpdateSelfGameStakePoolRequest) (*SelfGameStakePoolResponse, error)
AdjustSelfGameStakePool(context.Context, *AdjustSelfGameStakePoolRequest) (*AdjustSelfGameStakePoolResponse, error)
ListDiceRobots(context.Context, *ListDiceRobotsRequest) (*ListDiceRobotsResponse, error)
RegisterDiceRobots(context.Context, *RegisterDiceRobotsRequest) (*RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(context.Context, *SetDiceRobotStatusRequest) (*DiceRobotResponse, error)
@ -1238,8 +1290,20 @@ func (UnimplementedGameAdminServiceServer) ListSelfGames(context.Context, *ListS
func (UnimplementedGameAdminServiceServer) UpdateDiceConfig(context.Context, *UpdateDiceConfigRequest) (*DiceConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateDiceConfig not implemented")
}
func (UnimplementedGameAdminServiceServer) AdjustDicePool(context.Context, *AdjustDicePoolRequest) (*AdjustDicePoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdjustDicePool not implemented")
func (UnimplementedGameAdminServiceServer) GetSelfGameNewUserPolicy(context.Context, *GetSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetSelfGameNewUserPolicy not implemented")
}
func (UnimplementedGameAdminServiceServer) UpdateSelfGameNewUserPolicy(context.Context, *UpdateSelfGameNewUserPolicyRequest) (*SelfGameNewUserPolicyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateSelfGameNewUserPolicy not implemented")
}
func (UnimplementedGameAdminServiceServer) ListSelfGameStakePools(context.Context, *ListSelfGameStakePoolsRequest) (*ListSelfGameStakePoolsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListSelfGameStakePools not implemented")
}
func (UnimplementedGameAdminServiceServer) UpdateSelfGameStakePool(context.Context, *UpdateSelfGameStakePoolRequest) (*SelfGameStakePoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateSelfGameStakePool not implemented")
}
func (UnimplementedGameAdminServiceServer) AdjustSelfGameStakePool(context.Context, *AdjustSelfGameStakePoolRequest) (*AdjustSelfGameStakePoolResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdjustSelfGameStakePool not implemented")
}
func (UnimplementedGameAdminServiceServer) ListDiceRobots(context.Context, *ListDiceRobotsRequest) (*ListDiceRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListDiceRobots not implemented")
@ -1436,20 +1500,92 @@ func _GameAdminService_UpdateDiceConfig_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_AdjustDicePool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdjustDicePoolRequest)
func _GameAdminService_GetSelfGameNewUserPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSelfGameNewUserPolicyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).AdjustDicePool(ctx, in)
return srv.(GameAdminServiceServer).GetSelfGameNewUserPolicy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_AdjustDicePool_FullMethodName,
FullMethod: GameAdminService_GetSelfGameNewUserPolicy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).AdjustDicePool(ctx, req.(*AdjustDicePoolRequest))
return srv.(GameAdminServiceServer).GetSelfGameNewUserPolicy(ctx, req.(*GetSelfGameNewUserPolicyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_UpdateSelfGameNewUserPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateSelfGameNewUserPolicyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).UpdateSelfGameNewUserPolicy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_UpdateSelfGameNewUserPolicy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).UpdateSelfGameNewUserPolicy(ctx, req.(*UpdateSelfGameNewUserPolicyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListSelfGameStakePools_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSelfGameStakePoolsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListSelfGameStakePools(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListSelfGameStakePools_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListSelfGameStakePools(ctx, req.(*ListSelfGameStakePoolsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_UpdateSelfGameStakePool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateSelfGameStakePoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).UpdateSelfGameStakePool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_UpdateSelfGameStakePool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).UpdateSelfGameStakePool(ctx, req.(*UpdateSelfGameStakePoolRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_AdjustSelfGameStakePool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdjustSelfGameStakePoolRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).AdjustSelfGameStakePool(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_AdjustSelfGameStakePool_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).AdjustSelfGameStakePool(ctx, req.(*AdjustSelfGameStakePoolRequest))
}
return interceptor(ctx, in, info, handler)
}
@ -1674,8 +1810,24 @@ var GameAdminService_ServiceDesc = grpc.ServiceDesc{
Handler: _GameAdminService_UpdateDiceConfig_Handler,
},
{
MethodName: "AdjustDicePool",
Handler: _GameAdminService_AdjustDicePool_Handler,
MethodName: "GetSelfGameNewUserPolicy",
Handler: _GameAdminService_GetSelfGameNewUserPolicy_Handler,
},
{
MethodName: "UpdateSelfGameNewUserPolicy",
Handler: _GameAdminService_UpdateSelfGameNewUserPolicy_Handler,
},
{
MethodName: "ListSelfGameStakePools",
Handler: _GameAdminService_ListSelfGameStakePools_Handler,
},
{
MethodName: "UpdateSelfGameStakePool",
Handler: _GameAdminService_UpdateSelfGameStakePool_Handler,
},
{
MethodName: "AdjustSelfGameStakePool",
Handler: _GameAdminService_AdjustSelfGameStakePool_Handler,
},
{
MethodName: "ListDiceRobots",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
syntax = "proto3";
package hyapp.robot.v1;
option go_package = "hyapp.local/api/proto/robot/v1;robotv1";
// RequestMeta robot-service RPC app_code
message RequestMeta {
string request_id = 1;
string caller = 2;
int64 actor_user_id = 3;
int64 sent_at_ms = 4;
string app_code = 5;
}
message Robot {
string app_code = 1;
string robot_scope = 2;
string game_id = 3;
string room_scene = 4;
int64 user_id = 5;
string status = 6;
int64 created_by_admin_id = 7;
int64 created_at_ms = 8;
int64 updated_at_ms = 9;
int64 last_used_at_ms = 10;
int64 used_count = 11;
}
message ListGameRobotsRequest {
RequestMeta meta = 1;
string game_id = 2;
string status = 3;
int32 page_size = 4;
string cursor = 5;
}
message ListGameRobotsResponse {
repeated Robot robots = 1;
string next_cursor = 2;
int64 server_time_ms = 3;
}
message RegisterGameRobotsRequest {
RequestMeta meta = 1;
string game_id = 2;
repeated int64 user_ids = 3;
}
message RegisterGameRobotsResponse {
repeated Robot robots = 1;
int64 server_time_ms = 2;
}
message PickGameRobotRequest {
RequestMeta meta = 1;
string game_id = 2;
string match_id = 3;
int64 selected_at_ms = 4;
repeated int64 exclude_user_ids = 5;
}
message GameRobotResponse {
Robot robot = 1;
int64 server_time_ms = 2;
}
message SetGameRobotStatusRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
string status = 4;
}
message DeleteGameRobotRequest {
RequestMeta meta = 1;
string game_id = 2;
int64 user_id = 3;
}
message DeleteGameRobotResponse {
bool deleted = 1;
int64 server_time_ms = 2;
}
message ListRoomRobotsRequest {
RequestMeta meta = 1;
string room_scene = 2;
string status = 3;
int32 page_size = 4;
string cursor = 5;
}
message ListRoomRobotsResponse {
repeated Robot robots = 1;
string next_cursor = 2;
int64 server_time_ms = 3;
}
message RegisterRoomRobotsRequest {
RequestMeta meta = 1;
string room_scene = 2;
repeated int64 user_ids = 3;
}
message RegisterRoomRobotsResponse {
repeated Robot robots = 1;
int64 server_time_ms = 2;
}
message SetRoomRobotStatusRequest {
RequestMeta meta = 1;
string room_scene = 2;
int64 user_id = 3;
string status = 4;
}
message DeleteRoomRobotRequest {
RequestMeta meta = 1;
string room_scene = 2;
int64 user_id = 3;
}
message DeleteRoomRobotResponse {
bool deleted = 1;
int64 server_time_ms = 2;
}
message PickRoomRobotRequest {
RequestMeta meta = 1;
string room_scene = 2;
string room_id = 3;
int64 selected_at_ms = 4;
}
message RoomRobotResponse {
Robot robot = 1;
int64 server_time_ms = 2;
}
service GameRobotService {
rpc ListGameRobots(ListGameRobotsRequest) returns (ListGameRobotsResponse);
rpc RegisterGameRobots(RegisterGameRobotsRequest) returns (RegisterGameRobotsResponse);
rpc PickGameRobot(PickGameRobotRequest) returns (GameRobotResponse);
rpc SetGameRobotStatus(SetGameRobotStatusRequest) returns (GameRobotResponse);
rpc DeleteGameRobot(DeleteGameRobotRequest) returns (DeleteGameRobotResponse);
}
service RoomRobotService {
rpc ListRoomRobots(ListRoomRobotsRequest) returns (ListRoomRobotsResponse);
rpc RegisterRoomRobots(RegisterRoomRobotsRequest) returns (RegisterRoomRobotsResponse);
rpc PickRoomRobot(PickRoomRobotRequest) returns (RoomRobotResponse);
rpc SetRoomRobotStatus(SetRoomRobotStatusRequest) returns (RoomRobotResponse);
rpc DeleteRoomRobot(DeleteRoomRobotRequest) returns (DeleteRoomRobotResponse);
}

View File

@ -0,0 +1,527 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// source: proto/robot/v1/robot.proto
package robotv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GameRobotService_ListGameRobots_FullMethodName = "/hyapp.robot.v1.GameRobotService/ListGameRobots"
GameRobotService_RegisterGameRobots_FullMethodName = "/hyapp.robot.v1.GameRobotService/RegisterGameRobots"
GameRobotService_PickGameRobot_FullMethodName = "/hyapp.robot.v1.GameRobotService/PickGameRobot"
GameRobotService_SetGameRobotStatus_FullMethodName = "/hyapp.robot.v1.GameRobotService/SetGameRobotStatus"
GameRobotService_DeleteGameRobot_FullMethodName = "/hyapp.robot.v1.GameRobotService/DeleteGameRobot"
)
// GameRobotServiceClient is the client API for GameRobotService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type GameRobotServiceClient interface {
ListGameRobots(ctx context.Context, in *ListGameRobotsRequest, opts ...grpc.CallOption) (*ListGameRobotsResponse, error)
RegisterGameRobots(ctx context.Context, in *RegisterGameRobotsRequest, opts ...grpc.CallOption) (*RegisterGameRobotsResponse, error)
PickGameRobot(ctx context.Context, in *PickGameRobotRequest, opts ...grpc.CallOption) (*GameRobotResponse, error)
SetGameRobotStatus(ctx context.Context, in *SetGameRobotStatusRequest, opts ...grpc.CallOption) (*GameRobotResponse, error)
DeleteGameRobot(ctx context.Context, in *DeleteGameRobotRequest, opts ...grpc.CallOption) (*DeleteGameRobotResponse, error)
}
type gameRobotServiceClient struct {
cc grpc.ClientConnInterface
}
func NewGameRobotServiceClient(cc grpc.ClientConnInterface) GameRobotServiceClient {
return &gameRobotServiceClient{cc}
}
func (c *gameRobotServiceClient) ListGameRobots(ctx context.Context, in *ListGameRobotsRequest, opts ...grpc.CallOption) (*ListGameRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListGameRobotsResponse)
err := c.cc.Invoke(ctx, GameRobotService_ListGameRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameRobotServiceClient) RegisterGameRobots(ctx context.Context, in *RegisterGameRobotsRequest, opts ...grpc.CallOption) (*RegisterGameRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RegisterGameRobotsResponse)
err := c.cc.Invoke(ctx, GameRobotService_RegisterGameRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameRobotServiceClient) PickGameRobot(ctx context.Context, in *PickGameRobotRequest, opts ...grpc.CallOption) (*GameRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GameRobotResponse)
err := c.cc.Invoke(ctx, GameRobotService_PickGameRobot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameRobotServiceClient) SetGameRobotStatus(ctx context.Context, in *SetGameRobotStatusRequest, opts ...grpc.CallOption) (*GameRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GameRobotResponse)
err := c.cc.Invoke(ctx, GameRobotService_SetGameRobotStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameRobotServiceClient) DeleteGameRobot(ctx context.Context, in *DeleteGameRobotRequest, opts ...grpc.CallOption) (*DeleteGameRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGameRobotResponse)
err := c.cc.Invoke(ctx, GameRobotService_DeleteGameRobot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameRobotServiceServer is the server API for GameRobotService service.
// All implementations must embed UnimplementedGameRobotServiceServer
// for forward compatibility.
type GameRobotServiceServer interface {
ListGameRobots(context.Context, *ListGameRobotsRequest) (*ListGameRobotsResponse, error)
RegisterGameRobots(context.Context, *RegisterGameRobotsRequest) (*RegisterGameRobotsResponse, error)
PickGameRobot(context.Context, *PickGameRobotRequest) (*GameRobotResponse, error)
SetGameRobotStatus(context.Context, *SetGameRobotStatusRequest) (*GameRobotResponse, error)
DeleteGameRobot(context.Context, *DeleteGameRobotRequest) (*DeleteGameRobotResponse, error)
mustEmbedUnimplementedGameRobotServiceServer()
}
// UnimplementedGameRobotServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGameRobotServiceServer struct{}
func (UnimplementedGameRobotServiceServer) ListGameRobots(context.Context, *ListGameRobotsRequest) (*ListGameRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListGameRobots not implemented")
}
func (UnimplementedGameRobotServiceServer) RegisterGameRobots(context.Context, *RegisterGameRobotsRequest) (*RegisterGameRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RegisterGameRobots not implemented")
}
func (UnimplementedGameRobotServiceServer) PickGameRobot(context.Context, *PickGameRobotRequest) (*GameRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PickGameRobot not implemented")
}
func (UnimplementedGameRobotServiceServer) SetGameRobotStatus(context.Context, *SetGameRobotStatusRequest) (*GameRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetGameRobotStatus not implemented")
}
func (UnimplementedGameRobotServiceServer) DeleteGameRobot(context.Context, *DeleteGameRobotRequest) (*DeleteGameRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteGameRobot not implemented")
}
func (UnimplementedGameRobotServiceServer) mustEmbedUnimplementedGameRobotServiceServer() {}
func (UnimplementedGameRobotServiceServer) testEmbeddedByValue() {}
// UnsafeGameRobotServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GameRobotServiceServer will
// result in compilation errors.
type UnsafeGameRobotServiceServer interface {
mustEmbedUnimplementedGameRobotServiceServer()
}
func RegisterGameRobotServiceServer(s grpc.ServiceRegistrar, srv GameRobotServiceServer) {
// If the following call panics, it indicates UnimplementedGameRobotServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GameRobotService_ServiceDesc, srv)
}
func _GameRobotService_ListGameRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListGameRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameRobotServiceServer).ListGameRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameRobotService_ListGameRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameRobotServiceServer).ListGameRobots(ctx, req.(*ListGameRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameRobotService_RegisterGameRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterGameRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameRobotServiceServer).RegisterGameRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameRobotService_RegisterGameRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameRobotServiceServer).RegisterGameRobots(ctx, req.(*RegisterGameRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameRobotService_PickGameRobot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PickGameRobotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameRobotServiceServer).PickGameRobot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameRobotService_PickGameRobot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameRobotServiceServer).PickGameRobot(ctx, req.(*PickGameRobotRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameRobotService_SetGameRobotStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetGameRobotStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameRobotServiceServer).SetGameRobotStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameRobotService_SetGameRobotStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameRobotServiceServer).SetGameRobotStatus(ctx, req.(*SetGameRobotStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameRobotService_DeleteGameRobot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGameRobotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameRobotServiceServer).DeleteGameRobot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameRobotService_DeleteGameRobot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameRobotServiceServer).DeleteGameRobot(ctx, req.(*DeleteGameRobotRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameRobotService_ServiceDesc is the grpc.ServiceDesc for GameRobotService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GameRobotService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.robot.v1.GameRobotService",
HandlerType: (*GameRobotServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListGameRobots",
Handler: _GameRobotService_ListGameRobots_Handler,
},
{
MethodName: "RegisterGameRobots",
Handler: _GameRobotService_RegisterGameRobots_Handler,
},
{
MethodName: "PickGameRobot",
Handler: _GameRobotService_PickGameRobot_Handler,
},
{
MethodName: "SetGameRobotStatus",
Handler: _GameRobotService_SetGameRobotStatus_Handler,
},
{
MethodName: "DeleteGameRobot",
Handler: _GameRobotService_DeleteGameRobot_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/robot/v1/robot.proto",
}
const (
RoomRobotService_ListRoomRobots_FullMethodName = "/hyapp.robot.v1.RoomRobotService/ListRoomRobots"
RoomRobotService_RegisterRoomRobots_FullMethodName = "/hyapp.robot.v1.RoomRobotService/RegisterRoomRobots"
RoomRobotService_PickRoomRobot_FullMethodName = "/hyapp.robot.v1.RoomRobotService/PickRoomRobot"
RoomRobotService_SetRoomRobotStatus_FullMethodName = "/hyapp.robot.v1.RoomRobotService/SetRoomRobotStatus"
RoomRobotService_DeleteRoomRobot_FullMethodName = "/hyapp.robot.v1.RoomRobotService/DeleteRoomRobot"
)
// RoomRobotServiceClient is the client API for RoomRobotService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type RoomRobotServiceClient interface {
ListRoomRobots(ctx context.Context, in *ListRoomRobotsRequest, opts ...grpc.CallOption) (*ListRoomRobotsResponse, error)
RegisterRoomRobots(ctx context.Context, in *RegisterRoomRobotsRequest, opts ...grpc.CallOption) (*RegisterRoomRobotsResponse, error)
PickRoomRobot(ctx context.Context, in *PickRoomRobotRequest, opts ...grpc.CallOption) (*RoomRobotResponse, error)
SetRoomRobotStatus(ctx context.Context, in *SetRoomRobotStatusRequest, opts ...grpc.CallOption) (*RoomRobotResponse, error)
DeleteRoomRobot(ctx context.Context, in *DeleteRoomRobotRequest, opts ...grpc.CallOption) (*DeleteRoomRobotResponse, error)
}
type roomRobotServiceClient struct {
cc grpc.ClientConnInterface
}
func NewRoomRobotServiceClient(cc grpc.ClientConnInterface) RoomRobotServiceClient {
return &roomRobotServiceClient{cc}
}
func (c *roomRobotServiceClient) ListRoomRobots(ctx context.Context, in *ListRoomRobotsRequest, opts ...grpc.CallOption) (*ListRoomRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomRobotsResponse)
err := c.cc.Invoke(ctx, RoomRobotService_ListRoomRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomRobotServiceClient) RegisterRoomRobots(ctx context.Context, in *RegisterRoomRobotsRequest, opts ...grpc.CallOption) (*RegisterRoomRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RegisterRoomRobotsResponse)
err := c.cc.Invoke(ctx, RoomRobotService_RegisterRoomRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomRobotServiceClient) PickRoomRobot(ctx context.Context, in *PickRoomRobotRequest, opts ...grpc.CallOption) (*RoomRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RoomRobotResponse)
err := c.cc.Invoke(ctx, RoomRobotService_PickRoomRobot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomRobotServiceClient) SetRoomRobotStatus(ctx context.Context, in *SetRoomRobotStatusRequest, opts ...grpc.CallOption) (*RoomRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RoomRobotResponse)
err := c.cc.Invoke(ctx, RoomRobotService_SetRoomRobotStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomRobotServiceClient) DeleteRoomRobot(ctx context.Context, in *DeleteRoomRobotRequest, opts ...grpc.CallOption) (*DeleteRoomRobotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteRoomRobotResponse)
err := c.cc.Invoke(ctx, RoomRobotService_DeleteRoomRobot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomRobotServiceServer is the server API for RoomRobotService service.
// All implementations must embed UnimplementedRoomRobotServiceServer
// for forward compatibility.
type RoomRobotServiceServer interface {
ListRoomRobots(context.Context, *ListRoomRobotsRequest) (*ListRoomRobotsResponse, error)
RegisterRoomRobots(context.Context, *RegisterRoomRobotsRequest) (*RegisterRoomRobotsResponse, error)
PickRoomRobot(context.Context, *PickRoomRobotRequest) (*RoomRobotResponse, error)
SetRoomRobotStatus(context.Context, *SetRoomRobotStatusRequest) (*RoomRobotResponse, error)
DeleteRoomRobot(context.Context, *DeleteRoomRobotRequest) (*DeleteRoomRobotResponse, error)
mustEmbedUnimplementedRoomRobotServiceServer()
}
// UnimplementedRoomRobotServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedRoomRobotServiceServer struct{}
func (UnimplementedRoomRobotServiceServer) ListRoomRobots(context.Context, *ListRoomRobotsRequest) (*ListRoomRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRoomRobots not implemented")
}
func (UnimplementedRoomRobotServiceServer) RegisterRoomRobots(context.Context, *RegisterRoomRobotsRequest) (*RegisterRoomRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RegisterRoomRobots not implemented")
}
func (UnimplementedRoomRobotServiceServer) PickRoomRobot(context.Context, *PickRoomRobotRequest) (*RoomRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PickRoomRobot not implemented")
}
func (UnimplementedRoomRobotServiceServer) SetRoomRobotStatus(context.Context, *SetRoomRobotStatusRequest) (*RoomRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetRoomRobotStatus not implemented")
}
func (UnimplementedRoomRobotServiceServer) DeleteRoomRobot(context.Context, *DeleteRoomRobotRequest) (*DeleteRoomRobotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteRoomRobot not implemented")
}
func (UnimplementedRoomRobotServiceServer) mustEmbedUnimplementedRoomRobotServiceServer() {}
func (UnimplementedRoomRobotServiceServer) testEmbeddedByValue() {}
// UnsafeRoomRobotServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to RoomRobotServiceServer will
// result in compilation errors.
type UnsafeRoomRobotServiceServer interface {
mustEmbedUnimplementedRoomRobotServiceServer()
}
func RegisterRoomRobotServiceServer(s grpc.ServiceRegistrar, srv RoomRobotServiceServer) {
// If the following call panics, it indicates UnimplementedRoomRobotServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&RoomRobotService_ServiceDesc, srv)
}
func _RoomRobotService_ListRoomRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomRobotServiceServer).ListRoomRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomRobotService_ListRoomRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomRobotServiceServer).ListRoomRobots(ctx, req.(*ListRoomRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomRobotService_RegisterRoomRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRoomRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomRobotServiceServer).RegisterRoomRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomRobotService_RegisterRoomRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomRobotServiceServer).RegisterRoomRobots(ctx, req.(*RegisterRoomRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomRobotService_PickRoomRobot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PickRoomRobotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomRobotServiceServer).PickRoomRobot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomRobotService_PickRoomRobot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomRobotServiceServer).PickRoomRobot(ctx, req.(*PickRoomRobotRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomRobotService_SetRoomRobotStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetRoomRobotStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomRobotServiceServer).SetRoomRobotStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomRobotService_SetRoomRobotStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomRobotServiceServer).SetRoomRobotStatus(ctx, req.(*SetRoomRobotStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomRobotService_DeleteRoomRobot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteRoomRobotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomRobotServiceServer).DeleteRoomRobot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomRobotService_DeleteRoomRobot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomRobotServiceServer).DeleteRoomRobot(ctx, req.(*DeleteRoomRobotRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomRobotService_ServiceDesc is the grpc.ServiceDesc for RoomRobotService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var RoomRobotService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.robot.v1.RoomRobotService",
HandlerType: (*RoomRobotServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListRoomRobots",
Handler: _RoomRobotService_ListRoomRobots_Handler,
},
{
MethodName: "RegisterRoomRobots",
Handler: _RoomRobotService_RegisterRoomRobots_Handler,
},
{
MethodName: "PickRoomRobot",
Handler: _RoomRobotService_PickRoomRobot_Handler,
},
{
MethodName: "SetRoomRobotStatus",
Handler: _RoomRobotService_SetRoomRobotStatus_Handler,
},
{
MethodName: "DeleteRoomRobot",
Handler: _RoomRobotService_DeleteRoomRobot_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/robot/v1/robot.proto",
}

View File

@ -4914,8 +4914,10 @@ type AdminUpdateRoomRequest struct {
CloseReason string `protobuf:"bytes,8,opt,name=close_reason,json=closeReason,proto3" json:"close_reason,omitempty"`
AdminId uint64 `protobuf:"varint,9,opt,name=admin_id,json=adminId,proto3" json:"admin_id,omitempty"`
AdminName string `protobuf:"bytes,10,opt,name=admin_name,json=adminName,proto3" json:"admin_name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// owner_country_code 是内部运维/同步字段,用于通过真实 Room Cell owner 修复房主国家投影。
OwnerCountryCode *string `protobuf:"bytes,11,opt,name=owner_country_code,json=ownerCountryCode,proto3,oneof" json:"owner_country_code,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdminUpdateRoomRequest) Reset() {
@ -5018,6 +5020,13 @@ func (x *AdminUpdateRoomRequest) GetAdminName() string {
return ""
}
func (x *AdminUpdateRoomRequest) GetOwnerCountryCode() string {
if x != nil && x.OwnerCountryCode != nil {
return *x.OwnerCountryCode
}
return ""
}
type AdminUpdateRoomResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Result *CommandResult `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"`
@ -9893,7 +9902,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\aroom_id\x18\x02 \x01(\tR\x06roomId\"r\n" +
"\x14AdminGetRoomResponse\x124\n" +
"\x04room\x18\x01 \x01(\v2 .hyapp.room.v1.AdminRoomListItemR\x04room\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xc2\x03\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\x8c\x04\n" +
"\x16AdminUpdateRoomRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x19\n" +
"\x05title\x18\x02 \x01(\tH\x00R\x05title\x88\x01\x01\x12 \n" +
@ -9906,14 +9915,16 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\badmin_id\x18\t \x01(\x04R\aadminId\x12\x1d\n" +
"\n" +
"admin_name\x18\n" +
" \x01(\tR\tadminNameB\b\n" +
" \x01(\tR\tadminName\x121\n" +
"\x12owner_country_code\x18\v \x01(\tH\x06R\x10ownerCountryCode\x88\x01\x01B\b\n" +
"\x06_titleB\f\n" +
"\n" +
"_cover_urlB\x0e\n" +
"\f_descriptionB\a\n" +
"\x05_modeB\t\n" +
"\a_statusB\x14\n" +
"\x12_visible_region_id\"\x80\x01\n" +
"\x12_visible_region_idB\x15\n" +
"\x13_owner_country_code\"\x80\x01\n" +
"\x17AdminUpdateRoomResponse\x124\n" +
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12/\n" +
"\x04room\x18\x02 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"\x82\x01\n" +

View File

@ -592,6 +592,8 @@ message AdminUpdateRoomRequest {
string close_reason = 8;
uint64 admin_id = 9;
string admin_name = 10;
// owner_country_code / Room Cell owner
optional string owner_country_code = 11;
}
message AdminUpdateRoomResponse {

File diff suppressed because it is too large Load Diff

View File

@ -222,6 +222,17 @@ message InviteBDResponse {
RoleInvitation invitation = 1;
}
message InviteHostRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 inviter_user_id = 3;
int64 target_user_id = 4;
}
message InviteHostResponse {
RoleInvitation invitation = 1;
}
message ListBDLeaderBDsRequest {
RequestMeta meta = 1;
int64 leader_user_id = 2;
@ -255,6 +266,17 @@ message ListBDAgenciesResponse {
repeated Agency agencies = 1;
}
message ListRoleInvitationsRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string status = 3;
int32 page_size = 4;
}
message ListRoleInvitationsResponse {
repeated RoleInvitation invitations = 1;
}
message ProcessRoleInvitationRequest {
RequestMeta meta = 1;
string command_id = 2;
@ -486,9 +508,11 @@ service UserHostService {
rpc KickAgencyHost(KickAgencyHostRequest) returns (KickAgencyHostResponse);
rpc InviteAgency(InviteAgencyRequest) returns (InviteAgencyResponse);
rpc InviteBD(InviteBDRequest) returns (InviteBDResponse);
rpc InviteHost(InviteHostRequest) returns (InviteHostResponse);
rpc ListBDLeaderBDs(ListBDLeaderBDsRequest) returns (ListBDLeaderBDsResponse);
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);

View File

@ -25,9 +25,11 @@ const (
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
UserHostService_InviteHost_FullMethodName = "/hyapp.user.v1.UserHostService/InviteHost"
UserHostService_ListBDLeaderBDs_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderBDs"
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
UserHostService_ListRoleInvitations_FullMethodName = "/hyapp.user.v1.UserHostService/ListRoleInvitations"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
@ -52,9 +54,11 @@ type UserHostServiceClient interface {
KickAgencyHost(ctx context.Context, in *KickAgencyHostRequest, opts ...grpc.CallOption) (*KickAgencyHostResponse, error)
InviteAgency(ctx context.Context, in *InviteAgencyRequest, opts ...grpc.CallOption) (*InviteAgencyResponse, error)
InviteBD(ctx context.Context, in *InviteBDRequest, opts ...grpc.CallOption) (*InviteBDResponse, error)
InviteHost(ctx context.Context, in *InviteHostRequest, opts ...grpc.CallOption) (*InviteHostResponse, error)
ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
@ -135,6 +139,16 @@ func (c *userHostServiceClient) InviteBD(ctx context.Context, in *InviteBDReques
return out, nil
}
func (c *userHostServiceClient) InviteHost(ctx context.Context, in *InviteHostRequest, opts ...grpc.CallOption) (*InviteHostResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InviteHostResponse)
err := c.cc.Invoke(ctx, UserHostService_InviteHost_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListBDLeaderBDsResponse)
@ -165,6 +179,16 @@ func (c *userHostServiceClient) ListBDAgencies(ctx context.Context, in *ListBDAg
return out, nil
}
func (c *userHostServiceClient) ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoleInvitationsResponse)
err := c.cc.Invoke(ctx, UserHostService_ListRoleInvitations_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessRoleInvitationResponse)
@ -277,9 +301,11 @@ type UserHostServiceServer interface {
KickAgencyHost(context.Context, *KickAgencyHostRequest) (*KickAgencyHostResponse, error)
InviteAgency(context.Context, *InviteAgencyRequest) (*InviteAgencyResponse, error)
InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error)
InviteHost(context.Context, *InviteHostRequest) (*InviteHostResponse, error)
ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
@ -318,6 +344,9 @@ func (UnimplementedUserHostServiceServer) InviteAgency(context.Context, *InviteA
func (UnimplementedUserHostServiceServer) InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method InviteBD not implemented")
}
func (UnimplementedUserHostServiceServer) InviteHost(context.Context, *InviteHostRequest) (*InviteHostResponse, error) {
return nil, status.Error(codes.Unimplemented, "method InviteHost not implemented")
}
func (UnimplementedUserHostServiceServer) ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDLeaderBDs not implemented")
}
@ -327,6 +356,9 @@ func (UnimplementedUserHostServiceServer) ListBDLeaderAgencies(context.Context,
func (UnimplementedUserHostServiceServer) ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDAgencies not implemented")
}
func (UnimplementedUserHostServiceServer) ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRoleInvitations not implemented")
}
func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessRoleInvitation not implemented")
}
@ -486,6 +518,24 @@ func _UserHostService_InviteBD_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _UserHostService_InviteHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(InviteHostRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).InviteHost(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_InviteHost_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).InviteHost(ctx, req.(*InviteHostRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListBDLeaderBDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListBDLeaderBDsRequest)
if err := dec(in); err != nil {
@ -540,6 +590,24 @@ func _UserHostService_ListBDAgencies_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListRoleInvitations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoleInvitationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).ListRoleInvitations(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_ListRoleInvitations_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).ListRoleInvitations(ctx, req.(*ListRoleInvitationsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ProcessRoleInvitationRequest)
if err := dec(in); err != nil {
@ -751,6 +819,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "InviteBD",
Handler: _UserHostService_InviteBD_Handler,
},
{
MethodName: "InviteHost",
Handler: _UserHostService_InviteHost_Handler,
},
{
MethodName: "ListBDLeaderBDs",
Handler: _UserHostService_ListBDLeaderBDs_Handler,
@ -763,6 +835,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListBDAgencies",
Handler: _UserHostService_ListBDAgencies_Handler,
},
{
MethodName: "ListRoleInvitations",
Handler: _UserHostService_ListRoleInvitations_Handler,
},
{
MethodName: "ProcessRoleInvitation",
Handler: _UserHostService_ProcessRoleInvitation_Handler,

View File

@ -16058,15 +16058,23 @@ func (x *RetryRedPacketRefundResponse) GetServerTimeMs() int64 {
// CronBatchRequest 是 cron-service 触发 wallet-service 后台批处理的统一请求。
type CronBatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
RunId string `protobuf:"bytes,3,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
WorkerId string `protobuf:"bytes,4,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
BatchSize int32 `protobuf:"varint,5,opt,name=batch_size,json=batchSize,proto3" json:"batch_size,omitempty"`
LockTtlMs int64 `protobuf:"varint,6,opt,name=lock_ttl_ms,json=lockTtlMs,proto3" json:"lock_ttl_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
RunId string `protobuf:"bytes,3,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
WorkerId string `protobuf:"bytes,4,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
BatchSize int32 `protobuf:"varint,5,opt,name=batch_size,json=batchSize,proto3" json:"batch_size,omitempty"`
LockTtlMs int64 `protobuf:"varint,6,opt,name=lock_ttl_ms,json=lockTtlMs,proto3" json:"lock_ttl_ms,omitempty"`
// trigger_mode 为空时由 wallet-service 按 automatic 处理;后台手动结算显式传 manual。
TriggerMode string `protobuf:"bytes,7,opt,name=trigger_mode,json=triggerMode,proto3" json:"trigger_mode,omitempty"`
// cycle_key 为空时由 wallet-service 按当前 UTC 月处理;后台手动补结可指定周期。
CycleKey string `protobuf:"bytes,8,opt,name=cycle_key,json=cycleKey,proto3" json:"cycle_key,omitempty"`
// user_ids 为空表示批处理扫描全部候选;后台手动结算只传已勾选主播。
UserIds []int64 `protobuf:"varint,9,rep,packed,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"`
// settlement_role 为空时结算完整 Host/Agency 权益;后台人工入口可传 host 或 agency 拆分钱包。
SettlementRole string `protobuf:"bytes,10,opt,name=settlement_role,json=settlementRole,proto3" json:"settlement_role,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CronBatchRequest) Reset() {
@ -16141,6 +16149,34 @@ func (x *CronBatchRequest) GetLockTtlMs() int64 {
return 0
}
func (x *CronBatchRequest) GetTriggerMode() string {
if x != nil {
return x.TriggerMode
}
return ""
}
func (x *CronBatchRequest) GetCycleKey() string {
if x != nil {
return x.CycleKey
}
return ""
}
func (x *CronBatchRequest) GetUserIds() []int64 {
if x != nil {
return x.UserIds
}
return nil
}
func (x *CronBatchRequest) GetSettlementRole() string {
if x != nil {
return x.SettlementRole
}
return ""
}
// CronBatchResponse 返回一个批处理 run 的执行摘要。
type CronBatchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -17863,7 +17899,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x1cRetryRedPacketRefundResponse\x122\n" +
"\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12'\n" +
"\x0frefunded_amount\x18\x02 \x01(\x03R\x0erefundedAmount\x12$\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xbf\x01\n" +
"\x0eserver_time_ms\x18\x03 \x01(\x03R\fserverTimeMs\"\xc3\x02\n" +
"\x10CronBatchRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -17872,7 +17908,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\tworker_id\x18\x04 \x01(\tR\bworkerId\x12\x1d\n" +
"\n" +
"batch_size\x18\x05 \x01(\x05R\tbatchSize\x12\x1e\n" +
"\vlock_ttl_ms\x18\x06 \x01(\x03R\tlockTtlMs\"\xc6\x01\n" +
"\vlock_ttl_ms\x18\x06 \x01(\x03R\tlockTtlMs\x12!\n" +
"\ftrigger_mode\x18\a \x01(\tR\vtriggerMode\x12\x1b\n" +
"\tcycle_key\x18\b \x01(\tR\bcycleKey\x12\x19\n" +
"\buser_ids\x18\t \x03(\x03R\auserIds\x12'\n" +
"\x0fsettlement_role\x18\n" +
" \x01(\tR\x0esettlementRole\"\xc6\x01\n" +
"\x11CronBatchResponse\x12#\n" +
"\rclaimed_count\x18\x01 \x01(\x05R\fclaimedCount\x12'\n" +
"\x0fprocessed_count\x18\x02 \x01(\x05R\x0eprocessedCount\x12#\n" +

View File

@ -1795,6 +1795,14 @@ message CronBatchRequest {
string worker_id = 4;
int32 batch_size = 5;
int64 lock_ttl_ms = 6;
// trigger_mode wallet-service automatic manual
string trigger_mode = 7;
// cycle_key wallet-service UTC
string cycle_key = 8;
// user_ids
repeated int64 user_ids = 9;
// settlement_role Host/Agency host agency
string settlement_role = 10;
}
// CronBatchResponse run

View File

@ -83,6 +83,7 @@ func allServices() []serviceSpec {
appService("user-service", []int{13005, 13105}, "36"),
appService("activity-service", []int{13006, 13106}, "35"),
appService("wallet-service", []int{13004, 13104}, "33"),
appService("robot-service", []int{13011, 13111}, "93"),
appService("game-service", []int{13008, 13108}, "32"),
appService("room-service", []int{13001, 13101}, "34"),
appService("notice-service", []int{13009, 13109}, "96"),
@ -131,6 +132,9 @@ func selectServices(raw string) ([]serviceSpec, error) {
"as": "activity-service",
"cron": "cron-service",
"cs": "cron-service",
"robot": "robot-service",
"robots": "robot-service",
"rb": "robot-service",
"game": "game-service",
"games": "game-service",
"notice": "notice-service",

View File

@ -190,6 +190,27 @@ services:
retries: 20
start_period: 10s
robot-service:
build:
context: .
dockerfile: services/robot-service/Dockerfile
args: *go-build-args
container_name: robot-service
environment:
TZ: UTC
ports:
- "13011:13011"
- "13111:13111"
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13111/healthz/ready >/dev/null"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
game-service:
build:
context: .
@ -212,6 +233,8 @@ services:
condition: service_healthy
activity-service:
condition: service_healthy
robot-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13108/healthz/ready >/dev/null"]
interval: 5s
@ -290,6 +313,7 @@ services:
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/game-service/deploy/mysql/initdata/001_self_games.sql:/docker-entrypoint-initdb.d/010_game_service_data.sql:ro
- ./services/robot-service/deploy/mysql/initdb/001_robot_service.sql:/docker-entrypoint-initdb.d/011_robot_service.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro

View File

@ -3008,7 +3008,7 @@ definitions:
- host_center
- agency_center
- bd_center
- bd_leader_center
- superadmin_center
- coin_seller_center
- settings
- language

View File

@ -23,9 +23,9 @@
## 创建流程
1. 运营在后台选择创建数量和昵称语言。
2. admin-server 下载并转存头像
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号。
1. 运营在后台选择创建数量、国家、性别和语言元数据
2. admin-server 从 likei 线上 `user_base_info` 随机抽取正常用户的 `user_nickname``user_avatar`
3. admin-server 调 user-service 创建 `source=game_robot` 的资料号,昵称和头像直接使用 likei 资料
4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。
5. admin-server 调 game-service 把这些 user_id 注册到机器人池。
6. game-service 后续只从启用状态的机器人中选择补位。
@ -55,7 +55,7 @@
### 批量创建
- 地址:`POST /admin/game/robots/generate`
- 参数:`count``nicknameLanguage``gender``country``avatarUrls`
- 参数:`count`(默认 20最多 1000`nicknameLanguage`(只作为账号语言元数据)`gender``country`
- 返回值:创建数量、机器人列表
- 相关 IM机器人不导入 IM 登录账号
@ -75,6 +75,7 @@
## 游戏补位规则
- 自研游戏配置里开启机器人补位后玩家等待超过配置时间game-service 会选择启用机器人加入对局。
- 自研游戏配置里开启机器人补位后玩家等待超过配置时间game-service 会从当前 App 下所有启用且未加入本局的机器人里完全随机抽取一个加入对局。
- 机器人被选中后会记录最近使用时间和累计使用次数;这些字段只用于后台观察命中分布,不影响下一次随机抽取。
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
- 机器人禁用后不会再被新对局选中。

View File

@ -119,7 +119,7 @@ Authorization: Bearer <access_token>
| `agency_center` | `roles.is_agency == true` |
| `manager_center` | `roles.is_manager == true` |
| `bd_center` | `roles.is_bd == true` |
| `bd_leader_center` | `roles.is_bd_leader == true` |
| `superadmin_center` | `roles.is_bd_leader == true` |
| `coin_seller_center` | `roles.is_coin_seller == true` |
| `settings` | always true |
| `language` | always true |

View File

@ -106,7 +106,7 @@ gateway 只做三件事:
可信 IP 来源顺序:
```text
trusted edge header -> X-Forwarded-For first public IP -> RemoteAddr
X-Trusted-Client-IP -> X-Forwarded-For first public IP -> public X-Real-IP -> RemoteAddr
```
前端提交的 `ip` 字段不参与判断。旧客户端如果还传 IP只能写入审计扩展字段不能作为风控依据。
@ -500,6 +500,7 @@ login_risk:
unknown_ttl_sec: 1800
provider_timeout_ms: 800
providers:
- edge_country
- primary_geo
- fallback_geo_a
- fallback_geo_b
@ -515,6 +516,8 @@ tasks:
batch_size: 100
```
`primary_geo``fallback_geo_a``fallback_geo_b``fallback_geo_c` 的 HTTP 地址写死在 user-service 代码里,只用 YAML 控制调用顺序。登录主链路不等待这些外部 GeoIP providercron 异步任务命中被屏蔽国家后吊销 refresh session并写 session denylist 让 gateway 拒绝仍未过期的 access token。
配置原则:
- gateway 快速判断和 user-service IP 风控策略的不支持国家列表必须同源,避免 gateway 放行但后台风控用另一套规则。

View File

@ -0,0 +1,472 @@
# 自研游戏机器人策略与档位奖池产品文档
本文描述产品功能设计、运营目标、功能考量和原因
## 产品目标
### 强拉新
功能设计:
- 新用户进入自研游戏后,在低下注档位获得更好的早期体验。
- 新用户保护不等于必胜,而是让用户在前几局更容易获得正反馈。
- 新用户保护只在机器人局生效,不影响真人对战。
考量和原因:
- 新用户第一次玩自研游戏时,连续失败会降低继续下注和复玩意愿。
- 必胜容易被用户感知,也容易被批量新号套利,所以保护应表现为正反馈,而不是绝对必胜。
- 真人对战不能被保护策略影响,否则会破坏真人用户之间的公平预期。
### 中后期正常游玩
功能设计:
- 用户走出新手保护期后,机器人局回到正常随机体验。
- 老用户不配置固定胜率,不配置强制消耗强度。
- 奖池不足时优先收缩保护或熔断机器人补位,不把奖池保护做成用户必输。
考量和原因:
- 机器人存在的第一目标是保证快速开局和形成爽感,不是定向消耗用户金币。
- 新手期给予正反馈,成熟期回到正常随机,更接近用户可长期接受的体验曲线。
- 如果用户连赢后被强制输,用户更容易感知被控制,长期会损害复玩和付费信任。
### 风险可控
功能设计:
- 每个下注档位单独看奖池。
- 每个策略都有预算和水位限制。
- 每局机器人策略都能在后台查到原因。
考量和原因:
- 不同下注档位的资金压力完全不同,高档位不能消耗低档位积累。
- 策略没有预算限制会让奖池不可控。
- 没有策略原因和日志,运营无法解释用户投诉,也无法复盘奖池异常。
## 功能一:档位独立奖池
### 功能设计
每个自研游戏的每个下注档位都有独立奖池。
示例:
| 游戏 | 下注档位 | 奖池 |
| --- | ---: | --- |
| dice | 1000 | dice 1000 档奖池 |
| dice | 5000 | dice 5000 档奖池 |
| dice | 100000 | dice 100000 档奖池 |
| rock | 1000 | rock 1000 档奖池 |
后台在每个档位展示:
- 当前奖池余额。
- 绿色、黄色、红色、黑色健康状态。
- 最近 7 天入池。
- 最近 7 天出池。
- 最近 7 天净变化。
- 当前可承受真人赢次数。
### 考量和原因
- 1000 档和 100000 档不能共用一个奖池。100000 档真人赢一次的奖池支出,可能等于 1000 档很多局积累。
- 按档位隔离后,运营可以清楚看到哪个档位在赚钱、哪个档位在亏钱。
- “可承受真人赢次数”比单纯余额更直观,能告诉运营当前档位还能安全支撑多少次补贴或真人赢。
- 高档位奖池进入红色或黑色状态时,应优先保护奖池,而不是继续拉新或补贴。
### 兜底奖池账务口径
功能设计:
- 机器人局使用兜底奖池承担用户赢机器人时的净支出。
- 用户输给机器人时,用户扣除的积分回收到当前游戏和当前下注档位的兜底奖池。
- 机器人赢局不再从兜底奖池额外抽走平台扣点。
- 当机器人局长期回到 50% 基础体感时,兜底奖池期望接近不流失。
- 平台扣点主要从用户赢机器人时产生的派奖侧体现,具体账务拆分以技术评估后的结算模型为准。
考量和原因:
- 兜底奖池的定位是机器人侧资金池,不是平台收入池。
- 如果用户输机器人时还从奖池再抽平台扣点50% 基础体感下奖池也会持续流失,不利于长期运营。
- 机器人局的重点是控制用户体验成本和补贴成本,不能让基础 50% 体感也消耗兜底奖池。
- 该口径需要技术侧评估是否和现有平台费、入池比例兼容,产品上先明确目标:成熟期机器人局不应该持续打穿兜底奖池。
## 功能二:新用户独立保护
### 功能设计
新用户保护是一套独立策略,不读取成熟用户正常随机逻辑,也不读取任何旧固定胜率配置。
建议默认:
| 配置项 | 默认值 |
| --- | ---: |
| 保护开关 | 开启 |
| 保护时长 | 注册后 7 天 |
| 保护局数 | 前 30 局有效机器人局 |
| 最大保护档位 | 5000 |
| 生命周期补贴额度 | 30000 积分 |
| 首日补贴额度 | 15000 积分 |
| 单局保护上限 | 5000 积分 |
| 新手连输保护 | 开启 |
| 连输触发次数 | 连续 2 局 |
| 默认策略 | 轻度正反馈 |
可选策略:
| 策略 | 产品含义 |
| --- | --- |
| 不保护 | 只记录新用户阶段,不干预结果 |
| 首局不输 | 首局尽量不让用户输,可以赢或和 |
| 轻度正反馈 | 保护期内提高正反馈概率,但不做绝对必胜 |
| 强正反馈 | 活动期使用,给更强体验,但受奖池和风控限制 |
### 有效机器人局口径
功能设计:
- 有效机器人局只统计机器人补位局中已经完成结算、且结果不是平局的对局。
- 平局不计入有效机器人局。
- 平局不消耗新用户保护局数。
- 平局不消耗个人补贴额度。
- 真人局不计入有效机器人局,也不触发新用户保护。
- 新手连输保护的连输次数统计真人局和机器人局中已完成结算、且结果不是平局的自研游戏对局。
- 连输保护只影响下一次机器人补位局,不改写真人对战已经产生或正在进行的公平结算。
考量和原因:
- 平局没有给用户带来实际收益,也没有造成兜底奖池补贴支出,不应该消耗新手权益。
- 有效机器人局用于判断用户生命周期阶段,比“进入游戏次数”或“匹配次数”更准确。
- 只有完成结算的非平局机器人局才代表用户真实经历了一次胜负体验。
- 真人局虽然不消耗新手保护权益,但会影响用户的连续失败体验,因此需要计入连输保护判断。
- 连输保护放在机器人补位局执行,可以修复体验,又不会破坏真人对战的公平性。
### 个人补贴额度
功能设计:
- 生命周期补贴额度:限制用户整个新手保护期最多能消耗多少兜底奖池。
- 首日补贴额度:限制用户注册首日最多能消耗多少兜底奖池。
- 单局保护上限:限制单局最多按多少下注额享受保护。
- 剩余额度不足时,本局降级为正常随机,不再触发新用户保护。
- 用户输给机器人时,兜底奖池会回收积分,但个人补贴额度不恢复。
考量和原因:
- 只限制局数不够,用户可能在高频或高档位下快速消耗奖池。
- 首日额度可以降低批量新号当天快速套利的风险。
- 单局保护上限能防止用户用高档位放大新手保护收益。
- 补贴额度不恢复,可以避免用户通过反复输赢循环获得无限保护。
- 连输保护也必须受生命周期、首日、单局和奖池水位约束;额度不足时降级为正常随机,避免被批量新号稳定套利。
### 考量和原因
- 新用户保护的目标是拉新和留存,不是消耗。
- 新用户保护必须独立执行,否则拉新策略会被后续常规随机逻辑干扰。
- 新用户保护不建议设计成“必胜”,因为必胜会吸引批量新号。
- 保护必须限制低档位。高档位补贴成本太高,容易被用户利用低成本新号套利。
- 保护次数和保护时长都要有限制,避免用户长期停留在新手优惠状态。
- 保护还必须限制补贴额度,避免用户虽然局数不多但单局消耗过大。
## 功能三:成熟用户自然期目标胜率策略
### 功能设计
成熟用户指已经不满足新用户保护条件的用户。
成熟用户机器人局规则:
- 不按连赢、净赢、下注升级等行为强制用户输。
- 不配置老用户消耗强度。
- 不输出 `force_human_lose`
- 后台配置自然期目标胜率,默认 53%,可配置范围 50%-100%。
- 配置 50% 表示完全自然随机,不做任何结果干预。
- 配置高于 50% 时,只按目标胜率少量补正用户赢,不会强制用户输。
- dice 按无平局模型换算补正比例rock 按“平局不计有效机器人局”的口径换算补正比例。
- 红色水位回到正常随机。
- 黑色水位不抽机器人,用户继续等待真人。
### 考量和原因
- 成熟用户也需要可持续复玩体验,不能因为赢过几局就被系统强制回收。
- 50% 自然随机叠加平台费和入池后,对用户是负期望;默认 53% 用来抵消 5% 平台费和 1% 入池带来的长期负期望。
- 强制输策略会增加投诉风险和信任损耗,不符合“爽感”和长期沉浸目标。
- 奖池保护只负责资金安全,黑色时停止机器人补位,不能转化成让用户必输。
## 功能四:奖池水位保护
### 功能设计
每个档位奖池分为四个健康状态:
| 状态 | 产品规则 |
| --- | --- |
| 绿色 | 奖池余额充足,可以执行正常新用户保护和机器人随机局 |
| 黄色 | 奖池余额低于目标值,缩小正反馈和补贴强度 |
| 红色 | 奖池余额不足,回到 50% 基础体感,不再执行保护加成 |
| 黑色 | 可用余额不足以覆盖当前档位最大可能支出,暂停该档机器人匹配或只允许真人匹配 |
后台展示每个档位当前水位,并在保存策略时提示风险。
### 考量和原因
- 奖池水位是所有策略的资金底线。
- 新用户保护不能突破红色和黑色状态;成熟用户红色正常随机,黑色不补机器人。
- 黄色状态用于提前收紧策略,避免等到奖池不足才处理。
- 黑色状态必须熔断该档机器人补位,否则并发结算会把奖池打穿。
- 后台保存高风险配置时必须提示,否则运营可能误配。
## 功能五:风控准入
### 功能设计
风控准入在所有运营策略之前执行。命中风控的用户,不进入新用户保护。
第一阶段规则:
| 规则 | 产品含义 |
| --- | --- |
| 同设备新号过多 | 疑似批量账号 |
| 同 IP 新号过多 | 疑似工作室或机房 |
| 保护期收益过高 | 疑似薅新手保护 |
| 低档赢后快速冲高档 | 疑似资金放大套利 |
| 黑名单用户 | 后台或风控标记的异常用户 |
### 风险评分分层
功能设计:
风控输出 `risk_score`,后台按分层展示用户保护资格。
| risk_score | 风险层级 | 保护策略 |
| ---: | --- | --- |
| 0-29 | 正常用户 | 完整享受当前档位允许的新用户保护 |
| 30-49 | 轻度异常 | 降低保护强度,只保留低档位保护 |
| 50-69 | 中度异常 | 只保留 1000 档弱保护,关闭连输保护 |
| 70+ | 高风险套利 | 关闭全部新用户保护,机器人局回到 50% 基础体感 |
考量和原因:
- 单条风控规则只告诉运营“命中了什么”,风险评分能告诉运营“应该降到什么保护等级”。
- 分层处理比一刀切更适合运营,轻度异常用户仍可保留少量体验,中高风险用户直接防套利。
- 后台展示风险层级后,客服、运营和风控能用同一套语言解释用户为什么没有保护。
### 考量和原因
- 新用户保护是最大套利入口,必须先风控再补贴。
- 风控不是为了影响所有用户,而是排除明显异常用户。
- 第一阶段可以用设备、IP、收益和下注升级行为后续再扩展支付、转赠、提现等行为。
- 命中风控后仍可以正常游戏,只是不享受保护。
## 功能六:全量策略日志
### 功能设计
每局机器人局都记录策略日志。
后台可以按以下条件查询:
- 用户 ID。
- 对局 ID。
- 游戏。
- 下注档位。
- 策略类型。
- 决策结果。
- 时间范围。
每条日志展示:
- 用户阶段。
- 是否命中新用户保护。
- 是否命中风控。
- 当前奖池水位。
- 使用的策略。
- 最终决策。
- 决策原因。
### 考量和原因
- 机器人策略会影响用户体验和奖池资金,必须可追踪。
- 用户投诉时,需要解释这一局为什么赢、输或正常随机。
- 运营调策略后,需要用日志验证策略是否按预期命中。
- 风控复盘时,需要知道异常用户是否吃到了保护。
## 功能七:后台页面
### 基础配置页
功能设计:
- 游戏状态。
- 下注档位。
- 平台费。
- 入池比例。
- 机器人补位等待时间。
考量和原因:
- 这些属于游戏基础玩法配置,和用户阶段无关。
- 基础配置不应该和策略日志、风控规则混在一起。
### 档位奖池页
功能设计:
- 每个档位一行展示奖池状态。
- 支持后台手工加奖池和扣奖池。
- 支持查看档位奖池流水。
考量和原因:
- 运营需要直接处理某个档位奖池不足或过高的问题。
- 手工加扣必须可追踪,不能只改余额。
### 新用户保护页
功能设计:
- 配置保护开关、保护时长、保护局数、最大保护档位、补贴上限、策略强度。
- 展示新用户保护命中数、保护成本、保护后复玩。
考量和原因:
- 新用户保护是拉新功能,页面应围绕新用户体验和成本设计。
- 保护效果不能只看赢局,还要看继续玩和留存。
### 成熟用户观察页
功能设计:
- 不提供老用户消耗配置。
- 展示成熟用户机器人局胜负分布、平局率、复玩变化和奖池变化。
- 展示是否存在异常的强制输日志,正常目标为 0。
考量和原因:
- 成熟用户阶段以观察体验和资金曲线为主,不做单用户定向消耗。
- 保留观察指标可以及时发现自然随机、抽水和奖池账务是否偏离预期。
### 风控规则页
功能设计:
- 配置设备账号上限、IP 账号上限、保护收益上限、高档升级限制、黑名单。
考量和原因:
- 风控规则需要运营可见,否则新用户保护成本异常时无法快速处理。
- 黑名单用于人工兜底。
### 策略日志页
功能设计:
- 查询每局机器人局策略决策。
- 展示完整决策链路。
考量和原因:
- 策略日志是运营、客服、风控和研发共同排查问题的依据。
## 产品验收指标
### 匹配效率
| 验收项 | 上线目标 |
| --- | --- |
| 3 秒内开局率 | 大于等于 95%,高峰期不低于 90% |
| 机器人补位库存 | 不能因为机器人不足导致低档位长时间等待 |
原因:
- 机器人补位的第一目标是保证快速开局,如果开局速度不达标,后续体验策略没有意义。
### 新用户体验
| 验收项 | 上线目标 |
| --- | --- |
| 首局有效机器人局正反馈 | 只在 1000 档、低风险用户中触发 |
| 第 2-5 局体验曲线 | 命中轻度或强正反馈策略,允许统计波动 |
| 第 6-15 局体验曲线 | 正反馈强度开始下降,不能继续保持首局强度 |
| 第 16-30 局体验曲线 | 逐步接近常规体验 |
| 第 31 局后 | 进入后台配置的自然期目标胜率,默认 53%,不继续吃新手保护 |
原因:
- 产品验收不能只看单局胜负,要看用户是否按生命周期自然从强拉新过渡到常规体验。
### 高档位保护
| 验收项 | 上线目标 |
| --- | --- |
| 100000 档新手保护命中率 | 0% |
| 40000 档强保护命中率 | 0% |
| 高档位异常正反馈 | 触发后台预警 |
原因:
- 高档位是奖池风险和套利风险最高的区域,不能被新手保护放大成本。
### 奖池和账务
| 验收项 | 上线目标 |
| --- | --- |
| 黑色状态 | 不应出现;出现后自动熔断该档机器人补位 |
| 日终账务差异 | 0 |
| 奖池流水 | 每一笔入池、出池、冻结、释放都有记录 |
| 50% 基础体感下兜底奖池 | 期望接近不流失 |
原因:
- 策略可以灰度调整,但资金口径必须准确,奖池不能被并发或误配打穿。
### 风控和预警
| 验收项 | 上线目标 |
| --- | --- |
| 高风险用户保护命中率 | 0% |
| 同设备多号补贴异常 | 触发预警 |
| 单档位 1 小时消耗过快 | 自动收缩保护并预警 |
| 阶段体验曲线偏离 | 连续偏离时触发排查 |
原因:
- 新用户保护一旦上线,就必须同步验证套利拦截和策略偏差。
## 功能优先级
| 优先级 | 功能 | 原因 |
| ---: | --- | --- |
| P0 | 档位独立奖池 | 没有档位奖池,策略没有资金边界 |
| P0 | 全量策略日志 | 没有日志,策略不可解释 |
| P0 | 奖池水位保护 | 保护资金底线 |
| P1 | 新用户独立保护 | 拉新核心功能 |
| P1 | 风控准入 | 防止新手保护套利 |
| P2 | 数据看板 | 策略运营需要持续观察 |
## 默认产品配置
| 模块 | 默认配置 |
| --- | --- |
| 档位奖池 | 每个下注档位独立 |
| 新用户保护 | 开启 |
| 新用户保护局数 | 3 |
| 新用户保护时长 | 24 小时 |
| 新用户最大保护档位 | 5000 |
| 新用户策略 | 轻度正反馈 |
| 成熟用户策略 | 自然期目标胜率默认 53%,最低 50% |
| 高档位策略 | 必须启用水位保护 |
| 风控准入 | 开启 |
| 策略日志 | 全量开启 |
## 风险和处理
| 风险 | 说明 | 处理 |
| --- | --- | --- |
| 用户感知风险 | 定向强制输会让用户觉得被控制 | 不提供老用户消耗策略,成熟用户只允许配置目标胜率向上补正 |
| 奖池风险 | 高档位补贴会快速消耗奖池 | 新手保护限制低档位,高档位启用水位保护 |
| 套利风险 | 批量新号会攻击新手保护 | 风控准入先执行 |
| 运营误配风险 | 新手保护配置过激会影响资金和体验 | 保存配置时展示风险提示和可承受次数 |

View File

@ -0,0 +1,845 @@
# 自研游戏机器人策略与档位奖池技术方案
本文只描述《自研游戏机器人策略与档位奖池产品文档》里功能设计的技术实现。产品目标、功能原因和运营策略以产品文档为准。
## 技术结论
- 策略、奖池、结算和 `forced_result` 仍在 `game-service` 内实现。
- `robot-service` 只负责机器人账号池和机器人抽取,不参与胜负策略。
- 奖池从 `app_code + game_id` 升级为 `app_code + game_id + stake_coin`
- 档位奖池必须支持余额、冻结额度、健康状态和黑色熔断。
- 机器人策略引擎只输出决策,不直接改钱包,不直接改数据库余额。
- 新用户保护按有效机器人局计数,平局不计数、不消耗补贴额度。
- 结算仍复用现有骰子点数和 RPS 手势反推机制。
- 所有策略决策、奖池变更和保护状态变更都必须幂等。
## 功能一:档位独立奖池技术实现
### 数据表
新增 `game_self_game_stake_pools`
```sql
CREATE TABLE IF NOT EXISTS game_self_game_stake_pools (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(64) NOT NULL,
stake_coin BIGINT NOT NULL,
balance_coin BIGINT NOT NULL DEFAULT 0,
reserved_coin BIGINT NOT NULL DEFAULT 0,
target_balance_coin BIGINT NOT NULL DEFAULT 0,
safe_balance_coin BIGINT NOT NULL DEFAULT 0,
warning_balance_coin BIGINT NOT NULL DEFAULT 0,
danger_balance_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, game_id, stake_coin)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏档位奖池';
```
新增 `game_self_game_stake_pool_transactions`
```sql
CREATE TABLE IF NOT EXISTS game_self_game_stake_pool_transactions (
app_code VARCHAR(32) NOT NULL,
adjustment_id VARCHAR(96) NOT NULL,
game_id VARCHAR(64) NOT NULL,
stake_coin BIGINT NOT NULL,
match_id VARCHAR(96) NOT NULL DEFAULT '',
user_id BIGINT NOT NULL DEFAULT 0,
direction VARCHAR(16) NOT NULL,
amount_coin BIGINT NOT NULL,
reserved_delta_coin BIGINT NOT NULL DEFAULT 0,
reason VARCHAR(64) NOT NULL,
balance_after BIGINT NOT NULL,
reserved_after BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, adjustment_id),
KEY idx_self_game_stake_pool_tx_pool_time(app_code, game_id, stake_coin, created_at_ms),
KEY idx_self_game_stake_pool_tx_match(app_code, match_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏档位奖池流水';
```
### 仓储能力
新增 repository 方法:
```go
GetStakePool(ctx, appCode, gameID string, stakeCoin int64) (StakePool, error)
ListStakePools(ctx, appCode, gameID string) ([]StakePool, error)
UpsertStakePoolConfig(ctx, pool StakePool, nowMS int64) (StakePool, error)
ReserveStakePool(ctx, reservation StakePoolReservation) (StakePoolReservation, error)
ReleaseStakePoolReservation(ctx, reservation StakePoolReservation) (StakePoolReservation, error)
AdjustStakePool(ctx, adjustment StakePoolAdjustment) (StakePoolAdjustment, error)
```
实现要求:
- `AdjustStakePool` 必须在事务内 `SELECT ... FOR UPDATE` 锁定单个档位奖池。
- `ReserveStakePool` 必须在机器人入座前冻结本局最大可能支出,防止并发局同时判断余额足够后扣穿奖池。
- `ReleaseStakePoolReservation` 必须在结算完成、取消或失败补偿时释放冻结额度。
- `direction=out` 时余额不能扣成负数。
- 同一个 `adjustment_id` 不能重复写流水。
- 同一个 `reservation_id` 不能重复冻结或重复释放。
- 对局结算写奖池流水后,要同步更新 match 的 `pool_delta_coin`
### 兜底奖池账务评估
机器人局账务需要从现有 `fee_bps + pool_bps` 口径中单独评估。
目标口径:
- 用户赢机器人:用户收到派奖,平台扣点从派奖侧体现,兜底奖池承担用户净赢额。
- 用户输机器人:用户扣除积分进入当前档位兜底奖池,机器人赢局不再从兜底奖池额外抽平台扣点。
- 平局:不改用户积分,不改奖池,不计有效机器人局。
- 成熟期机器人局回到 50% 基础体感时,兜底奖池长期期望接近不流失。
技术评估项:
- 当前 `SettleByHighestScoreWithPool``SettleRPSByGestureWithPool` 是否把机器人赢局也按 `fee_bps/pool_bps` 扣点。
- 当前 `dicePoolDelta` 是否需要按 `match_mode=robot` 分出新账务公式。
- `game_orders` 中平台收入、用户派奖和奖池变化是否能对账。
- 历史局继续按旧口径解释,新策略上线后只影响新机器人局。
### 兼容迁移
`game_self_game_pools` 不删除。上线迁移时新增档位奖池:
- 如果旧游戏奖池有余额,由运营按档位分配。
- 如果没有明确分配规则,默认只建档位奖池,余额为 0。
- 旧奖池只保留查询和回滚,不再作为新逻辑写入目标。
## 功能二:新用户独立保护技术实现
### 数据表
新增 `game_self_game_new_user_policies`
```sql
CREATE TABLE IF NOT EXISTS game_self_game_new_user_policies (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
protection_rounds INT NOT NULL DEFAULT 30,
protection_hours INT NOT NULL DEFAULT 168,
max_stake_coin BIGINT NOT NULL DEFAULT 5000,
lifetime_subsidy_quota_coin BIGINT NOT NULL DEFAULT 30000,
day1_subsidy_quota_coin BIGINT NOT NULL DEFAULT 15000,
single_round_subsidy_cap_coin BIGINT NOT NULL DEFAULT 5000,
strategy_level VARCHAR(32) NOT NULL DEFAULT 'soft_boost',
lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1,
lose_streak_trigger INT NOT NULL DEFAULT 2,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏新用户保护策略';
```
新增 `game_self_game_user_protection_states`
```sql
CREATE TABLE IF NOT EXISTS game_self_game_user_protection_states (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
used_rounds INT NOT NULL DEFAULT 0,
lifetime_subsidy_coin BIGINT NOT NULL DEFAULT 0,
day1_subsidy_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'active',
first_applied_at_ms BIGINT NOT NULL DEFAULT 0,
last_applied_at_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, game_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏用户新手保护状态';
```
新增保护消费幂等表。
```sql
CREATE TABLE IF NOT EXISTS game_self_game_user_protection_events (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(64) NOT NULL,
match_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
used_round_delta INT NOT NULL DEFAULT 0,
lifetime_subsidy_coin_delta BIGINT NOT NULL DEFAULT 0,
day1_subsidy_coin_delta BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, game_id, match_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏新手保护消耗幂等事件';
```
### 用户阶段判断
新增 `UserStageResolver`
```go
type UserStageResolver interface {
ResolveSelfGameUserStage(ctx context.Context, appCode, gameID string, userID int64, nowMS int64) (UserStage, error)
}
```
第一阶段数据来源:
- user-service 返回注册时间。
- game-service 自己统计该用户自研游戏有效机器人局结算次数。
- protection state 返回已使用保护次数、生命周期补贴金额和首日补贴金额。
### 有效机器人局计数
有效机器人局必须满足:
- `match_mode=robot`
- 对局已完成结算。
- 对局结果不是平局。
- 钱包和奖池账务已经成功。
不计入有效机器人局:
- 真人局。
- 平局。
- 取消局。
- 结算失败局。
- 钱包扣款或派奖失败局。
实现要求:
- `ConsumeProtectionIfNeeded` 只在有效机器人局后运行。
- 平局不插入 `game_self_game_user_protection_events`
- 平局不增加 `used_rounds`
- 平局不增加补贴消耗。
### 状态更新时机
新用户保护状态只在机器人局结算成功后更新。
不更新:
- 匹配取消。
- 平局。
- 钱包扣款失败。
- 钱包派奖失败。
- 对局失败。
实现:
- 在 `RollMatch` 完成钱包和奖池更新后调用 `ConsumeProtectionIfNeeded`
- `ConsumeProtectionIfNeeded` 先插入 `game_self_game_user_protection_events`
- 插入成功后再更新 `game_self_game_user_protection_states`
- 如果事件已存在,直接返回,避免重复消费保护次数。
- 剩余生命周期额度、首日额度或单局额度不足时,策略引擎必须返回 `none` 决策并降级到正常随机,不能继续输出新用户保护决策。
## 功能三:成熟用户自然期目标胜率技术实现
### 数据表
不新增成熟用户消耗配置表。自然期目标胜率复用 `game_self_game_new_user_policies` 作为同一套机器人补位体验策略配置。
新增字段:
- `normal_phase_target_win_rate_percent`
- 默认值53
- 范围50-100
- 50完全自然随机
- 大于 50只补正用户赢不输出用户强制输
线上如果已经存在旧 `game_self_game_consume_policies`,只保留数据库对象,不做读取、不做写入、不在 admin 暴露。
### 用户统计快照
保留查询:
```go
GetSelfGameUserStats(ctx, appCode, gameID string, userID int64, nowMS int64) (UserGameStats, error)
```
字段:
- 今日机器人局数。
- 今日净盈亏。
- 近 7 日净盈亏。
- 连赢次数。
- 连输次数。
- 今日新手保护干预次数。
- 最近一次策略干预时间。
统计快照用于新手连输保护、日志解释和后续看板;成熟用户阶段不使用这些统计去强制用户输。
### 策略输出
成熟用户策略只输出:
- `none`
- `POOL_RED_NORMAL_RANDOM`
- `NORMAL_PHASE_TARGET_BASELINE`
- `NORMAL_PHASE_TARGET_MISS`
- `NORMAL_PHASE_TARGET_HIT`
不输出 `force_human_lose`
当配置值大于 50 且随机命中目标补正时,输出 `force_human_win`,但 `forced_result` 使用不消耗新手保护额度的内部值,结算后不写入新手保护消耗事件。
换算规则:
- dice 无平局:补正概率 = `2 * target - 100`。例如目标 53%,补正概率约 6%。
- rock 有平局且平局不计有效机器人局:补正概率 = `(2 * target - 100) / (200 - target)`。例如目标 53%,补正概率约 4.1%。
- 红色水位仍回到完全自然随机。
- 黑色水位仍不抽机器人。
说明:
- dice 继续按自然随机点数结算。
- rock 继续按自然随机机器人手势结算,平局退款。
- 红色水位只取消保护加成,不改写成用户必输。
- 黑色水位在奖池保护层熔断机器人补位。
## 功能四:奖池水位保护技术实现
### 水位判断
新增:
```go
type PoolLevel string
const (
PoolLevelGreen PoolLevel = "green"
PoolLevelYellow PoolLevel = "yellow"
PoolLevelRed PoolLevel = "red"
PoolLevelBlack PoolLevel = "black"
)
```
判断规则:
```text
available_coin = balance_coin - reserved_coin
available_coin < required_pool_coin -> black
balance_coin <= danger_balance_coin -> red
balance_coin <= warning_balance_coin -> yellow
balance_coin >= safe_balance_coin -> green
其它 -> yellow
```
### 策略行为
- `black`:暂停该档机器人匹配,或只允许真人匹配。
- `red`:回到 50% 基础体感,不能输出新用户保护加成。
- `yellow`:按奖池健康度缩小保护强度,新用户强正反馈降级为轻度正反馈。
- `green`:允许执行配置策略。
### 资金校验
真人赢之前必须计算 `required_pool_coin`
```text
required_pool_coin = payout_coin - human_stake_coin
```
如果当前档位奖池余额小于 `required_pool_coin`,直接进入 `pool_guard`,输出 `none` 并熔断机器人补位。
### 黑色熔断
黑色状态处理:
- 当前档位不再触发机器人补位。
- 已经冻结的机器人局继续按已写入策略完成结算。
- 新进入的用户只等待真人匹配,或提示当前档位匹配繁忙。
- 后台必须展示黑色状态和触发原因。
实现要求:
- `attachRobotIfDue` 调用策略引擎前先判断当前档位是否黑色。
- 黑色时不调用 `robot-service` 抽机器人,避免抽到机器人后又无法入座。
- 黑色状态恢复到红色以上后,才允许重新开启该档机器人补位。
## 功能五:风控准入技术实现
### 数据表
新增 `game_self_game_risk_rules`
```sql
CREATE TABLE IF NOT EXISTS game_self_game_risk_rules (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
device_user_limit INT NOT NULL DEFAULT 3,
ip_user_limit INT NOT NULL DEFAULT 5,
protection_profit_limit_coin BIGINT NOT NULL DEFAULT 0,
high_stake_after_protection_limit_ms BIGINT NOT NULL DEFAULT 3600000,
full_protection_max_score INT NOT NULL DEFAULT 29,
reduced_protection_max_score INT NOT NULL DEFAULT 49,
weak_protection_max_score INT NOT NULL DEFAULT 69,
disable_protection_min_score INT NOT NULL DEFAULT 70,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人策略风控规则';
```
### 风控接口
新增内部接口:
```go
type RiskEvaluator interface {
EvaluateSelfGameRobotRisk(ctx context.Context, input RiskInput) (RiskResult, error)
}
```
第一阶段本地判断:
- protection state 中补贴收益是否超限。
- strategy logs 中保护命中是否异常。
- 用户是否保护期低档获利后快速进入高档。
第二阶段接 user-service 或风控服务:
- 同设备账号数。
- 同 IP 账号数。
- 黑名单。
### 风控结果
```go
type RiskResult struct {
RiskScore int32
RiskTier string
BlockProtection bool
BlockReason string
ReasonJSON string
}
```
只阻断保护,不阻断正常游戏。
### 风控分层
```text
0-29 -> normal完整保护
30-49 -> reduced降低保护强度只保留低档位保护
50-69 -> weak只保留 1000 档弱保护,关闭连输保护
70+ -> blocked关闭全部新用户保护
```
实现要求:
- 风控分层结果写入策略日志 `reason_json`
- 新用户保护策略根据 `risk_tier` 降级,而不是只看 `BlockProtection`
- 高风险只关闭保护,不阻止用户正常进入游戏。
## 功能六:全量策略日志技术实现
### 数据表
新增 `game_self_game_strategy_logs`
```sql
CREATE TABLE IF NOT EXISTS game_self_game_strategy_logs (
app_code VARCHAR(32) NOT NULL,
log_id VARCHAR(96) NOT NULL,
game_id VARCHAR(64) NOT NULL,
stake_coin BIGINT NOT NULL,
match_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
strategy_code VARCHAR(64) NOT NULL,
decision VARCHAR(64) NOT NULL,
force_result VARCHAR(32) NOT NULL DEFAULT '',
reason_code VARCHAR(64) NOT NULL,
reason_json JSON NULL,
pool_balance_coin BIGINT NOT NULL DEFAULT 0,
pool_level VARCHAR(32) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, log_id),
UNIQUE KEY uk_self_game_strategy_match_user(app_code, match_id, user_id),
KEY idx_self_game_strategy_user_time(app_code, user_id, created_at_ms),
KEY idx_self_game_strategy_game_time(app_code, game_id, stake_coin, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人策略决策日志';
```
### 写入时机
在机器人入座前写策略日志。
原因:
- 机器人入座时已经确定 `forced_result`
- 后续结算失败也能查到当时为什么做这个决策。
### 日志内容
`reason_json` 必须包含:
- `user_stage`
- `risk_result`
- `risk_score`
- `risk_tier`
- `pool_level`
- `pool_balance_coin`
- `pool_reserved_coin`
- `required_pool_coin`
- `effective_bot_game_count`
- `lifetime_subsidy_left`
- `day1_subsidy_left`
- `new_user_policy_snapshot`
- `user_stats_snapshot`
## 策略引擎
### 输入
```go
type RobotStrategyInput struct {
AppCode string
GameID string
MatchID string
UserID int64
StakeCoin int64
NowMS int64
UserStage UserStage
EffectiveBotGameCount int32
RecentStats UserGameStats
StakePool StakePoolSnapshot
NewUserPolicy NewUserPolicy
RiskResult RiskResult
}
```
### 输出
```go
type RobotStrategyDecision struct {
StrategyCode string
Decision string
ReasonCode string
ReasonJSON string
ForceResult string
RequiredPoolCoin int64
ReservationID string
ShouldConsumeProtection bool
}
```
### 决策顺序
```text
pool_guard
-> risk_block
-> new_user_protection
-> new_user_lose_streak_protection
-> normal_random
```
实现要求:
- 每个分支都返回明确 `strategy_code``reason_code`
- 每个分支都必须可单测。
- `50%``70%` 这类绝对胜率不再作为主配置。
- 平局必须返回不消耗有效机器人局和不消耗补贴额度的原因码。
- 当补贴额度不足时,必须返回 `NO_USER_QUOTA` 类原因码并降级为正常随机。
- 新手连输保护的 `LoseStreak` 必须来自同一自研游戏下真人局和机器人局的已结算非平局结果;平局不打断也不累计。
- 新手连输保护命中后只在机器人补位局输出 `force_human_win`,不能回写或改写真人对战结果。
## 现有链路改造点
### attachRobotIfDue
旧链路在机器人入座时直接判断奖池、新用户首胜和固定胜负控制。改造后:
```text
读取 match
读取 config
读取 stake pool
判断黑色熔断
抽机器人
读取 user stage
读取 risk result
读取 new user policy
读取 consume policy
调用 strategy engine
冻结 stake pool reservation
写 strategy log
按 decision 设置 forced_result
JoinDiceRobot
```
### RollMatch
当前结算后调用奖池更新。改造后:
```text
按现有逻辑扣款、结算、派奖
按 stake_coin 更新档位奖池
释放 stake pool reservation
如果策略命中新用户保护,结算成功后消费保护状态
MarkDiceMatchSettled
```
失败处理:
- 结算失败时释放冻结额度。
- 钱包扣款失败时释放冻结额度。
- 用户取消或匹配过期时释放冻结额度。
- 释放失败时写补偿任务或人工对账队列。
### dicePoolDelta
输入不变,输出增加 `stake_coin`
```go
func dicePoolDelta(match dicedomain.Match) (direction string, amount int64, userID int64, stakeCoin int64)
```
原因:奖池流水必须写入具体档位。
## Admin 后台技术实现
### 接口
#### 档位奖池列表
- 地址:`GET /admin/self-games/:game_id/stake-pools`
- 参数:
- `game_id`
- 返回值:
- `items[].stakeCoin`
- `items[].balanceCoin`
- `items[].reservedCoin`
- `items[].availableCoin`
- `items[].targetBalanceCoin`
- `items[].safeBalanceCoin`
- `items[].warningBalanceCoin`
- `items[].dangerBalanceCoin`
- `items[].poolLevel`
- `items[].recentInCoin`
- `items[].recentOutCoin`
- `items[].recentNetCoin`
- `items[].humanWinCapacity`
- `items[].botMatchEnabled`
- 相关 IM
#### 档位奖池配置
- 地址:`PUT /admin/self-games/:game_id/stake-pools/:stake_coin`
- 参数:
- `targetBalanceCoin`
- `safeBalanceCoin`
- `warningBalanceCoin`
- `dangerBalanceCoin`
- `status`
- 返回值:更新后的档位奖池
- 相关 IM
#### 档位奖池加扣
- 地址:`POST /admin/self-games/:game_id/stake-pools/:stake_coin/adjust`
- 参数:
- `direction`
- `amountCoin`
- `reason`
- 返回值:
- `adjustmentId`
- `balanceAfter`
- `reservedAfter`
- 相关 IM
#### 新用户保护配置
- 地址:`GET /v1/admin/game/self-games/:game_id/new-user-policy`
- 参数:无
- 返回值:当前配置
- 相关 IM
- 地址:`PATCH /v1/admin/game/self-games/:game_id/new-user-policy`
- 参数:
- `enabled`
- `protectionRounds`
- `protectionHours`
- `maxStakeCoin`
- `lifetimeSubsidyQuotaCoin`
- `day1SubsidyQuotaCoin`
- `singleRoundSubsidyCapCoin`
- `strategyLevel`
- `loseStreakProtectionEnabled`
- `loseStreakTrigger`
- 返回值:更新后的配置
- 相关 IM
#### 风控规则配置
- 地址:`GET /admin/self-games/:game_id/risk-rules`
- 参数:无
- 返回值:当前配置
- 相关 IM
- 地址:`PUT /admin/self-games/:game_id/risk-rules`
- 参数:
- `enabled`
- `deviceUserLimit`
- `ipUserLimit`
- `protectionProfitLimitCoin`
- `highStakeAfterProtectionLimitMs`
- `fullProtectionMaxScore`
- `reducedProtectionMaxScore`
- `weakProtectionMaxScore`
- `disableProtectionMinScore`
- 返回值:更新后的配置
- 相关 IM
#### 策略日志查询
- 地址:`GET /admin/self-games/strategy-logs`
- 参数:
- `gameId`
- `stakeCoin`
- `userId`
- `matchId`
- `strategyCode`
- `decision`
- `riskTier`
- `poolLevel`
- `startTimeMs`
- `endTimeMs`
- `pageSize`
- `cursor`
- 返回值:
- `items`
- `nextCursor`
- 相关 IM
### 前端页面
后台前端按产品文档拆成 6 个页签:
- 基础配置。
- 档位奖池。
- 新用户保护。
- 成熟用户观察。
- 风控规则。
- 策略日志。
保存配置时校验:
- 水位阈值必须满足 `danger(red) <= warning(yellow) <= safe(green)`
- `targetBalanceCoin` 必须大于等于 `safeBalanceCoin`,否则健康度比例没有参考值。
- 新用户最大保护档位必须存在于下注档位。
- 生命周期补贴额度、首日补贴额度、单局保护上限不能为负数。
- 单局保护上限不能大于新用户最大保护档位。
- 风险评分阈值必须满足 `fullProtectionMaxScore < reducedProtectionMaxScore < weakProtectionMaxScore < disableProtectionMinScore`
- 金额不能为负数。
## 灰度步骤
### 第一阶段shadow 日志
- 建表。
- 接入策略引擎。
- 只计算决策,不执行 `forced_result`
- 全量写策略日志。
验证:
- 日志覆盖所有机器人局。
- 策略命中分布符合预期。
### 第二阶段:档位奖池切换
- 新结算只写 `game_self_game_stake_pools`
- 旧 `game_self_game_pools` 不再写入。
- 后台奖池加扣只作用于档位奖池。
验证:
- 每档余额等于初始余额加流水净额。
- 并发扣减不会出现负数。
- 黑色状态时不再抽机器人补位。
- 冻结额度能在结算、取消和失败补偿后释放。
### 第三阶段:新用户保护灰度
- 只开低档位。
- 只开部分 App 或部分用户。
- 观察保护成本和复玩。
验证:
- 新用户策略不读取成熟用户正常随机逻辑。
- 命中风控的用户不消耗保护。
- 平局不计有效机器人局。
- 平局不消耗新用户补贴额度。
- 剩余额度不足时降级为正常随机。
- 成熟用户连赢或净赢后仍然不输出强制输。
## 测试计划
### Go 单测
- 风控命中时不进入新用户保护。
- 奖池黑色状态时不抽机器人。
- 奖池红色水位优先于新用户保护。
- 新用户保护不读取成熟用户正常随机逻辑。
- 成熟用户连赢、净赢和高频下注后不输出强制输。
- 自然期目标胜率 50 时不输出强制结果。
- 自然期目标胜率大于 50 命中时输出不消耗新手额度的用户赢。
- 平局不计有效机器人局。
- 平局不消耗补贴额度。
- 生命周期额度、首日额度、单局额度不足时降级为正常随机。
- 风险评分分层能正确降级保护。
- 新手连输保护命中后输出 `force_human_win`
- 非新手保护场景不输出 `force_human_lose`
- `force_human_win``none` 输出稳定。
- 策略日志 reason 完整。
### MySQL 单测
- 档位奖池并发扣减不能扣成负数。
- 档位奖池并发冻结不能超过可用余额。
- 结算成功后冻结额度释放。
- 取消或失败后冻结额度释放。
- 同一个 `adjustment_id` 不重复入流水。
- 同一局重复结算不重复扣奖池。
- 同一局重复结算不重复消耗新手保护次数。
- 平局不插入保护消费事件。
- 策略日志唯一键生效。
### Admin 测试
- 档位奖池列表展示水位和可承受次数。
- 档位奖池列表展示冻结额度、可用余额和黑色熔断状态。
- 档位奖池加扣成功后余额和流水一致。
- 新用户保护配置校验生命周期额度、首日额度、单局保护上限。
- 风控规则配置校验 risk_score 阈值递增。
- 策略日志分页查询正常。
### 产品验收校验
- 3 秒内开局率可按小时、游戏和档位统计。
- 100000 档新手保护命中率必须为 0。
- 40000 档强保护命中率必须为 0。
- 高风险用户保护命中率必须为 0。
- 日终奖池账务差异必须为 0。
- 50% 基础体感机器人局下,兜底奖池净变化需要接近 0若不接近必须回查账务公式。
- 自然期默认 53% 下,用户长期净变化应接近抽水后的体验目标,且新手补贴消耗不增加。
## 需要保留的兼容点
- 旧 `game_self_game_pools` 不删除。
- 旧 `game_self_game_pool_transactions` 不删除。
- 旧 `robot_user_win_rate_percent` 不再做任何 server/admin/API 兼容;线上库已有遗留列不删除,但新代码不读写、不暴露。
- 已结算历史局不回算档位奖池。
- 历史局仍按原 `pool_delta_coin` 解释。
## 实施顺序
1. 建表和仓储。
2. 策略引擎 shadow 模式。
3. 策略日志后台查询。
4. 档位奖池后台列表和加扣。
5. 档位奖池切换结算。
6. 新用户保护灰度。
7. 风控准入接入。
8. 成熟用户观察看板。
9. 告警。

View File

@ -0,0 +1,68 @@
# 自研游戏机器人策略测试用例
本文用于验证《自研游戏机器人策略与档位奖池产品文档》和《自研游戏机器人策略与档位奖池技术方案》里的核心策略,不覆盖语音房 `room-rps`
## 测试范围
- 自研 `dice`
- 自研 `rock`
- 机器人补位局。
- 真人对战局对用户连赢连输统计的影响。
- 档位独立奖池。
- 新用户保护、连输保护、成熟用户正常随机、奖池水位、风控准入、策略日志。
## P0 用例
| 编号 | 场景 | 输入 | 预期 |
| --- | --- | --- | --- |
| TC-P0-001 | 旧固定胜率不可用 | 后台保存自研游戏配置 | 请求和返回都不包含旧固定胜率字段 |
| TC-P0-002 | 档位奖池隔离 | dice 1000 和 dice 100000 同时下注 | 两个档位奖池余额、流水、健康状态互不影响 |
| TC-P0-003 | 黑色水位熔断 | 当前档位可用余额小于真人赢机器人最大净支出 | 不抽机器人,不写入机器人座位,策略日志记录 `POOL_BLACK` |
| TC-P0-004 | 红色水位回到基础随机 | 当前档位余额进入红色但仍能覆盖本局最大支出 | 不输出新用户保护强制结果,也不强制用户输 |
| TC-P0-005 | 策略日志完整 | 任意机器人补位尝试 | 记录用户阶段、风险分层、水位、策略、原因码、奖池快照 |
| TC-P0-006 | 奖池账务差异 | 大量机器人局结算后对账 | 初始余额 + 入池 - 出池 - 期末余额 = 0 |
## 新用户保护用例
| 编号 | 场景 | 输入 | 预期 |
| --- | --- | --- | --- |
| TC-NEW-001 | 低档新用户正反馈 | 新用户、低风险、1000 或 5000 档、绿色水位 | 根据阶段概率输出正反馈,命中后 `force_human_win` |
| TC-NEW-002 | 新用户连输保护 | 新用户最近连续 2 局非平局失败,失败来自真人局或机器人局 | 下一次机器人补位局跳过普通概率,直接 `NEW_USER_LOSE_STREAK_PROTECTION` |
| TC-NEW-003 | 真人局不消耗保护局数 | 新用户真人对战胜负 | 更新连赢连输和净输赢,但不增加有效机器人局和保护消耗 |
| TC-NEW-004 | rock 平局不消耗 | rock 机器人局平局 | 不增加有效机器人局,不增加保护局数,不增加补贴额度 |
| TC-NEW-005 | 生命周期额度不足 | 本局所需补贴超过剩余生命周期额度 | 降级正常随机,不输出保护强制结果 |
| TC-NEW-006 | 首日额度不足 | 本局所需补贴超过首日剩余额度 | 降级正常随机,不输出保护强制结果 |
| TC-NEW-007 | 单局保护上限不足 | 本局所需补贴超过单局保护上限 | 降级正常随机,不输出保护强制结果 |
| TC-NEW-008 | 高档位保护禁用 | 40000 或 100000 档新用户 | 新用户保护命中率为 0 |
## 成熟用户自然期目标胜率用例
| 编号 | 场景 | 输入 | 预期 |
| --- | --- | --- | --- |
| TC-OLD-001 | 50% 基线 | 成熟用户,后台自然期目标胜率配置 50 | 不输出强制结果,完全自然随机 |
| TC-OLD-002 | 默认 53% 命中 | 成熟用户,后台自然期目标胜率配置 53随机命中补正 | 输出用户赢,但不消耗新手保护额度 |
| TC-OLD-003 | 默认 53% 未命中 | 成熟用户,后台自然期目标胜率配置 53随机未命中补正 | 不输出强制结果,继续自然随机 |
| TC-OLD-004 | 红色水位正常随机 | 成熟用户所在档位进入红色水位但仍能覆盖支出 | 输出 `POOL_RED_NORMAL_RANDOM`,不输出 `force_human_lose` |
| TC-OLD-005 | 黑色水位熔断 | 成熟用户所在档位可用余额不足 | 不抽机器人,不结算为用户必输 |
| TC-OLD-006 | 连赢后不强制输 | 成熟用户连续赢达到任意阈值 | 不输出 `force_human_lose` |
## 风控用例
| 编号 | 场景 | 输入 | 预期 |
| --- | --- | --- | --- |
| TC-RISK-001 | 正常用户 | `risk_score` 0-29 | 可按水位和预算享受完整保护 |
| TC-RISK-002 | 轻度异常 | `risk_score` 30-49 | 保护强度降低 |
| TC-RISK-003 | 中度异常 | `risk_score` 50-69 | 保护强度明显降低 |
| TC-RISK-004 | 高风险 | `risk_score` 70+ | 新用户保护命中率 0但用户仍能正常随机游戏 |
## 随机模拟验收
随机模拟每次必须满足:
- 不固定 seed。
- 随机源来自系统随机数。
- 至少 1000 个用户。
- 用户下注档位、游戏、真人/机器人局、风险分层、初始状态都随机生成。
- 输出每个用户的完整轨迹。
- 输出汇总指标和失败项。
- 若出现高档位保护命中、高风险用户保护命中、rock 平局消耗保护、强制用户输、账务差异非 0则模拟不通过。

View File

@ -40,18 +40,8 @@ type GameOutboxMessage struct {
// EncodeGameOutboxMessage serializes a game fact for fanout through RocketMQ.
func EncodeGameOutboxMessage(message GameOutboxMessage) ([]byte, error) {
message.MessageType = MessageTypeGameOutboxEvent
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.GameID) == "" ||
message.OccurredAtMS <= 0 {
return nil, errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0) {
return nil, errors.New("game order outbox message is incomplete")
if err := validateGameOutboxMessage(message); err != nil {
return nil, err
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
@ -68,21 +58,30 @@ func DecodeGameOutboxMessage(body []byte) (GameOutboxMessage, error) {
if message.MessageType != MessageTypeGameOutboxEvent {
return GameOutboxMessage{}, errors.New("unexpected game outbox message_type")
}
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.GameID) == "" ||
message.OccurredAtMS <= 0 {
return GameOutboxMessage{}, errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0) {
return GameOutboxMessage{}, errors.New("game order outbox message is incomplete")
if err := validateGameOutboxMessage(message); err != nil {
return GameOutboxMessage{}, err
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
return message, nil
}
func validateGameOutboxMessage(message GameOutboxMessage) error {
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.GameID) == "" ||
message.OccurredAtMS <= 0 {
return errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount < 0) {
// 厂商可能发送 0 金额派奖或 0 金额结算确认;这类订单是已落库事实,必须允许 MQ 投递。
// 负数仍然不是合法订单金额,扣减方向必须由 op_type=debit 表达,不能把符号塞进金额字段。
return errors.New("game order outbox message is incomplete")
}
return nil
}

View File

@ -0,0 +1,67 @@
package gamemq
import "testing"
func TestEncodeGameOutboxMessageAllowsZeroCoinGameOrder(t *testing.T) {
message := GameOutboxMessage{
AppCode: "lalu",
EventID: "GameOrderSettled:gord_zero_credit",
EventType: EventTypeGameOrderSettled,
OrderID: "gord_zero_credit",
UserID: 42,
PlatformCode: "zeeone",
GameID: "1021",
OpType: "credit",
CoinAmount: 0,
OccurredAtMS: 1781199149295,
}
body, err := EncodeGameOutboxMessage(message)
if err != nil {
t.Fatalf("zero coin game order should be publishable: %v", err)
}
decoded, err := DecodeGameOutboxMessage(body)
if err != nil {
t.Fatalf("zero coin game order should be decodable: %v", err)
}
if decoded.CoinAmount != 0 || decoded.PayloadJSON != "{}" {
t.Fatalf("decoded zero coin order mismatch: %+v", decoded)
}
}
func TestEncodeGameOutboxMessageRejectsNegativeGameOrderAmount(t *testing.T) {
_, err := EncodeGameOutboxMessage(GameOutboxMessage{
AppCode: "lalu",
EventID: "GameOrderSettled:gord_negative",
EventType: EventTypeGameOrderSettled,
OrderID: "gord_negative",
UserID: 42,
PlatformCode: "zeeone",
GameID: "1021",
OpType: "credit",
CoinAmount: -1,
OccurredAtMS: 1781199149295,
})
if err == nil {
t.Fatalf("negative game order amount should be rejected")
}
}
func TestEncodeGameOutboxMessageAllowsSelfGameEventWithoutOrderFields(t *testing.T) {
body, err := EncodeGameOutboxMessage(GameOutboxMessage{
AppCode: "lalu",
EventID: "SelfGameMatchSettled:match_1",
EventType: EventTypeSelfGameMatchSettled,
UserID: 42,
PlatformCode: "self",
GameID: "dice",
PayloadJSON: `{"match_id":"match_1"}`,
OccurredAtMS: 1781199149295,
})
if err != nil {
t.Fatalf("self game fact should not require order fields: %v", err)
}
if _, err := DecodeGameOutboxMessage(body); err != nil {
t.Fatalf("self game fact should decode: %v", err)
}
}

View File

@ -9,6 +9,7 @@ import (
"fmt"
"net"
"strings"
"sync"
"time"
rocketmq "github.com/apache/rocketmq-client-go/v2"
@ -173,7 +174,9 @@ func (p *Producer) SendSync(ctx context.Context, message Message) error {
// Consumer wraps a clustering push consumer.
type Consumer struct {
client rocketmq.PushConsumer
mu sync.Mutex
client rocketmq.PushConsumer
started bool
}
// NewConsumer creates a RocketMQ push consumer. Subscribe before Start.
@ -256,7 +259,19 @@ func (c *Consumer) Start() error {
if c == nil || c.client == nil {
return errors.New("rocketmq consumer is not configured")
}
return c.client.Start()
c.mu.Lock()
defer c.mu.Unlock()
if c.started {
return nil
}
// RocketMQ 的 PushConsumer 只有 Start 成功后才具备完整的内部统计状态。
// 本地 namesrv 未启动时 Start 会返回错误,此时必须保持 started=false
// 上层为了回滚已启动消费者会调用 Shutdown未启动 consumer 直接跳过,避免依赖库在半初始化状态下 panic。
if err := c.client.Start(); err != nil {
return err
}
c.started = true
return nil
}
// Shutdown releases the underlying consumer.
@ -264,6 +279,16 @@ func (c *Consumer) Shutdown() error {
if c == nil || c.client == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.started {
return nil
}
defer func() {
// 无论底层关闭是否成功,本 wrapper 都不再重复调用 Shutdown
// RocketMQ client 的二次关闭或半初始化关闭不具备幂等保证,重复尝试只会放大退出路径风险。
c.started = false
}()
return c.client.Shutdown()
}

View File

@ -21,6 +21,7 @@ SQL_FILES=(
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"services/robot-service/deploy/mysql/initdb/001_robot_service.sql"
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"
"services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/999_local_grants.sql"

View File

@ -0,0 +1,8 @@
USE hyapp_wallet;
ALTER TABLE resources
MODIFY COLUMN manager_grant_enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '管理端是否允许发放';
UPDATE resources
SET manager_grant_enabled = FALSE
WHERE manager_grant_enabled <> FALSE;

View File

@ -98,6 +98,7 @@ print_row "wallet-service" "gRPC" "grpc" "wallet-service" "13004" "13004"
print_row "user-service" "gRPC" "grpc" "user-service" "13005" "13005"
print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
print_row "cron-service" "gRPC" "grpc" "cron-service" "13007" "13007"
print_row "robot-service" "gRPC" "grpc" "robot-service" "13011" "13011"
print_row "game-service" "gRPC" "grpc" "game-service" "13008" "13008"
print_row "notice-service" "gRPC" "grpc" "notice-service" "13009" "13009"
print_row "statistics-service" "Health" "http" "statistics-service" "13110" "13110" "/healthz/ready"

View File

@ -80,6 +80,7 @@ import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
func main() {
@ -149,6 +150,22 @@ func main() {
}
defer walletDB.Close()
var robotProfileSource gamemanagementmodule.RobotProfileSource
if strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) != "" {
robotProfileDB, err := sql.Open("mysql", cfg.RobotProfileSource.MySQLDSN)
if err != nil {
fatalRuntime("connect_robot_profile_mysql_failed", err)
}
robotProfileCtx, robotProfileCancel := context.WithTimeout(context.Background(), cfg.RobotProfileSource.RequestTimeout)
if err := robotProfileDB.PingContext(robotProfileCtx); err != nil {
robotProfileCancel()
fatalRuntime("ping_robot_profile_mysql_failed", err)
}
robotProfileCancel()
defer robotProfileDB.Close()
robotProfileSource = gamemanagementmodule.NewLikeiRobotProfileSource(robotProfileDB)
}
store := repository.New(db)
if cfg.MySQLAutoMigrate {
if err := store.AutoMigrate(); err != nil {
@ -236,19 +253,19 @@ func main() {
CPRelation: cprelationmodule.New(userDB, auditHandler),
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store, cfg),
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn)),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Game: gamemanagementmodule.New(
gameclient.NewGRPC(gameConn),
userclient.NewGRPC(userConn),
auditHandler,
gamemanagementmodule.WithRobotAvatarUploader(objectUploader, cfg.TencentCOS.ObjectPrefix),
gamemanagementmodule.WithRobotProfileSource(robotProfileSource),
),
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
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),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB),
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),

View File

@ -11,6 +11,9 @@ http_addr: "127.0.0.1:13100"
mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
robot_profile_source:
mysql_dsn: "likei_reader:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=UTC"
request_timeout: "5s"
mysql_auto_migrate: false
migrations:
enabled: true
@ -24,6 +27,7 @@ cors_allowed_origins:
- "http://43.128.56.40"
- "http://hyapp-platform.global-interaction.com"
- "https://hyapp-platform.global-interaction.com"
- "https://api-acc.global-interaction.com"
refresh_cookie_secure: true
refresh_cookie_same_site: "none"
bootstrap:

View File

@ -11,6 +11,9 @@ http_addr: ":13100"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
robot_profile_source:
mysql_dsn: ""
request_timeout: "5s"
mysql_auto_migrate: true
migrations:
enabled: true

View File

@ -13,33 +13,34 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
}
type MigrationConfig struct {
@ -78,6 +79,11 @@ type StatisticsServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type RobotProfileSourceConfig struct {
MySQLDSN string `yaml:"mysql_dsn"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -115,14 +121,17 @@ type JobsConfig struct {
func Default() Config {
return Config{
ServiceName: "admin-server",
NodeID: "admin-local",
Environment: "local",
Log: logging.DefaultConfig(),
HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
ServiceName: "admin-server",
NodeID: "admin-local",
Environment: "local",
Log: logging.DefaultConfig(),
HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC",
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
RobotProfileSource: RobotProfileSourceConfig{
RequestTimeout: 5 * time.Second,
},
MySQLAutoMigrate: true,
Migrations: MigrationConfig{
Enabled: true,
@ -290,6 +299,10 @@ func (cfg *Config) Normalize() {
if cfg.StatisticsService.RequestTimeout <= 0 {
cfg.StatisticsService.RequestTimeout = 3 * time.Second
}
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
if cfg.RobotProfileSource.RequestTimeout <= 0 {
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -399,6 +412,12 @@ func (cfg Config) Validate() error {
if cfg.StatisticsService.RequestTimeout <= 0 {
return errors.New("statistics_service.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")
}
if cfg.RobotProfileSource.RequestTimeout <= 0 {
return errors.New("robot_profile_source.request_timeout must be greater than 0")
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")

View File

@ -27,6 +27,7 @@ type Client interface {
GetInviteActivityRewardConfig(ctx context.Context, req *activityv1.GetInviteActivityRewardConfigRequest) (*activityv1.GetInviteActivityRewardConfigResponse, error)
UpdateInviteActivityRewardConfig(ctx context.Context, req *activityv1.UpdateInviteActivityRewardConfigRequest) (*activityv1.UpdateInviteActivityRewardConfigResponse, error)
ListInviteActivityRewardClaims(ctx context.Context, req *activityv1.ListInviteActivityRewardClaimsRequest) (*activityv1.ListInviteActivityRewardClaimsResponse, error)
ListInviteActivityRewardSummaries(ctx context.Context, req *activityv1.ListInviteActivityRewardSummariesRequest) (*activityv1.ListInviteActivityRewardSummariesResponse, error)
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
@ -153,6 +154,10 @@ func (c *GRPCClient) ListInviteActivityRewardClaims(ctx context.Context, req *ac
return c.inviteActivityClient.ListInviteActivityRewardClaims(ctx, req)
}
func (c *GRPCClient) ListInviteActivityRewardSummaries(ctx context.Context, req *activityv1.ListInviteActivityRewardSummariesRequest) (*activityv1.ListInviteActivityRewardSummariesResponse, error) {
return c.inviteActivityClient.ListInviteActivityRewardSummaries(ctx, req)
}
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
}

View File

@ -18,7 +18,11 @@ type Client interface {
DeleteCatalog(ctx context.Context, req *gamev1.DeleteCatalogRequest) (*gamev1.DeleteCatalogResponse, error)
ListSelfGames(ctx context.Context, req *gamev1.ListSelfGamesRequest) (*gamev1.ListSelfGamesResponse, error)
UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDiceConfigRequest) (*gamev1.DiceConfigResponse, error)
AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error)
GetSelfGameNewUserPolicy(ctx context.Context, req *gamev1.GetSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error)
UpdateSelfGameNewUserPolicy(ctx context.Context, req *gamev1.UpdateSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error)
ListSelfGameStakePools(ctx context.Context, req *gamev1.ListSelfGameStakePoolsRequest) (*gamev1.ListSelfGameStakePoolsResponse, error)
UpdateSelfGameStakePool(ctx context.Context, req *gamev1.UpdateSelfGameStakePoolRequest) (*gamev1.SelfGameStakePoolResponse, error)
AdjustSelfGameStakePool(ctx context.Context, req *gamev1.AdjustSelfGameStakePoolRequest) (*gamev1.AdjustSelfGameStakePoolResponse, error)
ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error)
RegisterDiceRobots(ctx context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error)
SetDiceRobotStatus(ctx context.Context, req *gamev1.SetDiceRobotStatusRequest) (*gamev1.DiceRobotResponse, error)
@ -71,8 +75,24 @@ func (c *GRPCClient) UpdateDiceConfig(ctx context.Context, req *gamev1.UpdateDic
return c.client.UpdateDiceConfig(ctx, req)
}
func (c *GRPCClient) AdjustDicePool(ctx context.Context, req *gamev1.AdjustDicePoolRequest) (*gamev1.AdjustDicePoolResponse, error) {
return c.client.AdjustDicePool(ctx, req)
func (c *GRPCClient) GetSelfGameNewUserPolicy(ctx context.Context, req *gamev1.GetSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error) {
return c.client.GetSelfGameNewUserPolicy(ctx, req)
}
func (c *GRPCClient) UpdateSelfGameNewUserPolicy(ctx context.Context, req *gamev1.UpdateSelfGameNewUserPolicyRequest) (*gamev1.SelfGameNewUserPolicyResponse, error) {
return c.client.UpdateSelfGameNewUserPolicy(ctx, req)
}
func (c *GRPCClient) ListSelfGameStakePools(ctx context.Context, req *gamev1.ListSelfGameStakePoolsRequest) (*gamev1.ListSelfGameStakePoolsResponse, error) {
return c.client.ListSelfGameStakePools(ctx, req)
}
func (c *GRPCClient) UpdateSelfGameStakePool(ctx context.Context, req *gamev1.UpdateSelfGameStakePoolRequest) (*gamev1.SelfGameStakePoolResponse, error) {
return c.client.UpdateSelfGameStakePool(ctx, req)
}
func (c *GRPCClient) AdjustSelfGameStakePool(ctx context.Context, req *gamev1.AdjustSelfGameStakePoolRequest) (*gamev1.AdjustSelfGameStakePoolResponse, error) {
return c.client.AdjustSelfGameStakePool(ctx, req)
}
func (c *GRPCClient) ListDiceRobots(ctx context.Context, req *gamev1.ListDiceRobotsRequest) (*gamev1.ListDiceRobotsResponse, error) {

View File

@ -508,6 +508,7 @@ type BDProfile struct {
CreatedByUsername string `json:"createdByUsername"`
CreatedByAvatar string `json:"createdByAvatar"`
SubBDCount int64 `json:"subBdCount"`
SalaryWallet SalaryWallet `json:"salaryWallet"`
}
type Agency struct {
@ -529,6 +530,7 @@ type Agency struct {
ParentBDUsername string `json:"parentBdUsername"`
ParentBDAvatar string `json:"parentBdAvatar"`
RegionName string `json:"regionName"`
SalaryWallet SalaryWallet `json:"salaryWallet"`
}
type HostProfile struct {
@ -551,6 +553,16 @@ type HostProfile struct {
CurrentAgencyOwnerDisplayUserID string `json:"currentAgencyOwnerDisplayUserId"`
CurrentAgencyOwnerUsername string `json:"currentAgencyOwnerUsername"`
CurrentAgencyOwnerAvatar string `json:"currentAgencyOwnerAvatar"`
SalaryWallet SalaryWallet `json:"salaryWallet"`
}
type SalaryWallet struct {
Role string `json:"role"`
UserID int64 `json:"userId,string"`
AssetType string `json:"assetType"`
AvailableAmount int64 `json:"availableAmount"`
FrozenAmount int64 `json:"frozenAmount"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type CoinSellerProfile struct {

View File

@ -8,6 +8,7 @@ import (
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
)
type AppUserBanRecord struct {
@ -117,15 +118,16 @@ func banListWhereSQL(ctx context.Context, query banListQuery, adminIDs []int64)
LEFT JOIN users operator_user ON l.operator_type = 'app_user' AND operator_user.app_code = l.app_code AND operator_user.user_id = l.operator_user_id
WHERE l.app_code = ? AND target.status IN ('banned', 'disabled')`
args := []any{appCode, appCode}
nowMs := nowMillis()
if query.TargetKeyword != "" {
like := "%" + query.TargetKeyword + "%"
whereSQL += " AND (CAST(target.user_id AS CHAR) LIKE ? OR target.current_display_user_id LIKE ? OR target.username LIKE ?)"
args = append(args, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("target", "target.user_id", query.TargetKeyword, nowMs)
whereSQL += " AND " + matchSQL
args = append(args, matchArgs...)
}
if query.OperatorKeyword != "" {
like := "%" + query.OperatorKeyword + "%"
operatorClauses := []string{"(l.operator_type = 'app_user' AND (CAST(operator_user.user_id AS CHAR) LIKE ? OR operator_user.current_display_user_id LIKE ? OR operator_user.username LIKE ?))"}
args = append(args, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("operator_user", "operator_user.user_id", query.OperatorKeyword, nowMs)
operatorClauses := []string{"(l.operator_type = 'app_user' AND " + matchSQL + ")"}
args = append(args, matchArgs...)
if len(adminIDs) > 0 {
operatorClauses = append(operatorClauses, "(l.operator_type = 'admin' AND l.operator_user_id IN ("+placeholders(len(adminIDs))+"))")
for _, id := range adminIDs {

View File

@ -15,20 +15,25 @@ func TestBanListWhereSQLBuildsTargetKeywordFilter(t *testing.T) {
"target.status IN ('banned', 'disabled')",
"CAST(target.user_id AS CHAR) LIKE ?",
"target.current_display_user_id LIKE ?",
"target.default_display_user_id LIKE ?",
"target.username LIKE ?",
"pretty_display_user_id_leases lease",
} {
if !strings.Contains(whereSQL, fragment) {
t.Fatalf("where sql missing %q: %s", fragment, whereSQL)
}
}
if len(args) != 5 {
t.Fatalf("args length = %d, want 5: %#v", len(args), args)
if len(args) != 8 {
t.Fatalf("args length = %d, want 8: %#v", len(args), args)
}
for index := 2; index < len(args); index++ {
for _, index := range []int{2, 3, 4, 5, 7} {
if args[index] != "%10086%" {
t.Fatalf("target keyword arg[%d] = %#v, want %%10086%%", index, args[index])
}
}
if _, ok := args[6].(int64); !ok {
t.Fatalf("pretty lease expiry arg type = %T, want int64: %+v", args[6], args)
}
}
func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) {
@ -41,6 +46,8 @@ func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) {
for _, fragment := range []string{
"l.operator_type = 'app_user'",
"operator_user.current_display_user_id LIKE ?",
"operator_user.default_display_user_id LIKE ?",
"pretty_display_user_id_leases lease",
"(l.operator_type = 'admin' AND l.operator_user_id IN (?,?))",
"l.created_at_ms >= ?",
"l.created_at_ms < ?",
@ -49,11 +56,17 @@ func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) {
t.Fatalf("where sql missing %q: %s", fragment, whereSQL)
}
}
wantArgs := []any{"lalu", "lalu", "%admin%", "%admin%", "%admin%", int64(7), int64(9), int64(1000), int64(2000)}
wantArgs := []any{"lalu", "lalu", "%admin%", "%admin%", "%admin%", "%admin%", int64(0), "%admin%", int64(7), int64(9), int64(1000), int64(2000)}
if len(args) != len(wantArgs) {
t.Fatalf("args length = %d, want %d: %#v", len(args), len(wantArgs), args)
}
for index := range wantArgs {
if index == 6 {
if _, ok := args[index].(int64); !ok {
t.Fatalf("arg[%d] type = %T, want int64: %#v", index, args[index], args)
}
continue
}
if args[index] != wantArgs[index] {
t.Fatalf("arg[%d] = %#v, want %#v", index, args[index], wantArgs[index])
}

View File

@ -8,6 +8,7 @@ import (
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
)
type AppLoginLog struct {
@ -97,13 +98,15 @@ func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any)
LEFT JOIN users u ON u.app_code = la.app_code AND u.user_id = la.user_id
WHERE la.app_code = ?`
args := []any{appctx.FromContext(ctx)}
nowMs := nowMillis()
if query.UserID > 0 {
whereSQL += " AND la.user_id = ?"
args = append(args, query.UserID)
}
if query.DisplayUserID != "" {
whereSQL += " AND u.current_display_user_id = ?"
args = append(args, query.DisplayUserID)
matchSQL, matchArgs := shared.UserDisplayIDSQL("u", query.DisplayUserID, nowMs)
whereSQL += " AND " + matchSQL
args = append(args, matchArgs...)
}
if query.IP != "" {
whereSQL += " AND la.client_ip = ?"
@ -145,8 +148,10 @@ func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
whereSQL += " AND (CAST(la.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR la.client_ip LIKE ?)"
args = append(args, like, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "la.user_id", query.Keyword, nowMs)
whereSQL += " AND (" + matchSQL + " OR la.client_ip LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
}
return whereSQL, args
}

View File

@ -14,6 +14,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/security"
activityv1 "hyapp.local/api/proto/activity/v1"
)
@ -76,7 +77,8 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
}
query = normalizeListQuery(query)
appCode := appctx.FromContext(ctx)
whereSQL, args := appUserListWhereSQL(appCode, query)
nowMs := nowMillis()
whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs)
total, err := countRows(ctx, s.userDB, whereSQL, args...)
if err != nil {
@ -84,10 +86,9 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
}
if query.SortBy == "coin" {
// 金币余额来自 wallet 库,必须先补齐当前筛选结果的余额再排序分页,避免只按当前页局部重排。
items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args)
items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args, nowMs)
return items, total, err
}
nowMs := nowMillis()
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
u.current_display_user_id,
@ -148,8 +149,7 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
return items, total, nil
}
func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any) ([]AppUser, error) {
nowMs := nowMillis()
func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any, nowMs int64) ([]AppUser, error) {
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
SELECT u.user_id,
u.current_display_user_id,
@ -589,6 +589,10 @@ func normalizeListQuery(query listQuery) listQuery {
}
func appUserListWhereSQL(appCode string, query listQuery) (string, []any) {
return appUserListWhereSQLAt(appCode, query, nowMillis())
}
func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string, []any) {
whereSQL := "FROM users u WHERE u.app_code = ?"
args := []any{appCode}
if query.Status != "" {
@ -596,9 +600,9 @@ func appUserListWhereSQL(appCode string, query listQuery) (string, []any) {
args = append(args, query.Status)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
whereSQL += " AND (CAST(u.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
args = append(args, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "u.user_id", query.Keyword, nowMs)
whereSQL += " AND " + matchSQL
args = append(args, matchArgs...)
}
if query.Country != "" {
// 国家筛选用 users.country 的 ISO code 精确命中;前端传展示名时不会被猜测转换,避免误筛到错误国家。

View File

@ -3,6 +3,8 @@ package appuser
import (
"strings"
"testing"
"hyapp-admin-server/internal/modules/shared"
)
func TestNormalizeListQuerySort(t *testing.T) {
@ -36,6 +38,8 @@ func TestAppUserListWhereSQLFilters(t *testing.T) {
"u.status = ?",
"CAST(u.user_id AS CHAR) LIKE ?",
"UPPER(u.country) = ?",
"u.default_display_user_id LIKE ?",
"pretty_display_user_id_leases lease",
"u.region_id = ?",
"u.created_at_ms >= ?",
"u.created_at_ms < ?",
@ -44,9 +48,22 @@ func TestAppUserListWhereSQLFilters(t *testing.T) {
t.Fatalf("where sql missing %q: %s", want, whereSQL)
}
}
if len(args) != 9 || args[0] != "lalu" || args[5] != "PH" || args[6] != int64(3) || args[7] != int64(1000) || args[8] != int64(2000) {
if len(args) != 12 ||
args[0] != "lalu" ||
args[2] != "%hunter%" ||
args[3] != "%hunter%" ||
args[4] != "%hunter%" ||
args[5] != "%hunter%" ||
args[7] != "%hunter%" ||
args[8] != "PH" ||
args[9] != int64(3) ||
args[10] != int64(1000) ||
args[11] != int64(2000) {
t.Fatalf("where args mismatch: %+v", args)
}
if _, ok := args[6].(int64); !ok {
t.Fatalf("pretty lease expiry arg type = %T, want int64: %+v", args[6], args)
}
whereSQL, args = appUserListWhereSQL("lalu", normalizeListQuery(listQuery{RegionID: 0, RegionIDSet: true}))
if !strings.Contains(whereSQL, "COALESCE(u.region_id, 0) = 0") || !strings.Contains(whereSQL, "NOT EXISTS") {
@ -57,6 +74,58 @@ func TestAppUserListWhereSQLFilters(t *testing.T) {
}
}
func TestAppUserDisplayIDSQLMatchesDefaultCurrentAndPrettyLease(t *testing.T) {
whereSQL, args := shared.UserDisplayIDSQL("u", "bd100", 12345)
for _, want := range []string{
"u.current_display_user_id = ?",
"u.default_display_user_id = ?",
"pretty_display_user_id_leases lease",
"lease.display_user_id = ?",
"lease.expires_at_ms > ?",
} {
if !strings.Contains(whereSQL, want) {
t.Fatalf("where sql missing %q: %s", want, whereSQL)
}
}
wantArgs := []any{"bd100", "bd100", int64(12345), "bd100"}
if len(args) != len(wantArgs) {
t.Fatalf("args length = %d, want %d: %#v", len(args), len(wantArgs), args)
}
for index := range wantArgs {
if args[index] != wantArgs[index] {
t.Fatalf("arg[%d] = %#v, want %#v", index, args[index], wantArgs[index])
}
}
}
func TestLoginLogWhereSQLUsesSharedUserKeywordFilter(t *testing.T) {
whereSQL, args := loginLogWhereSQL(nil, loginLogQuery{
Keyword: "bd100",
DisplayUserID: "vip888",
})
for _, want := range []string{
"u.current_display_user_id = ?",
"u.default_display_user_id = ?",
"CAST(la.user_id AS CHAR) LIKE ?",
"u.default_display_user_id LIKE ?",
"pretty_display_user_id_leases lease",
"la.client_ip LIKE ?",
} {
if !strings.Contains(whereSQL, want) {
t.Fatalf("where sql missing %q: %s", want, whereSQL)
}
}
if len(args) != 12 || args[1] != "vip888" || args[2] != "vip888" || args[4] != "vip888" || args[5] != "%bd100%" || args[11] != "%bd100%" {
t.Fatalf("args mismatch: %+v", args)
}
if _, ok := args[3].(int64); !ok {
t.Fatalf("display id pretty expiry arg type = %T, want int64: %+v", args[3], args)
}
if _, ok := args[9].(int64); !ok {
t.Fatalf("keyword pretty expiry arg type = %T, want int64: %+v", args[9], args)
}
}
func TestAppUserOrderSQL(t *testing.T) {
asc := appUserOrderSQL(listQuery{SortBy: "created_at", SortDirection: "asc"})
if asc != "ORDER BY u.created_at_ms ASC, u.user_id ASC" {

View File

@ -43,6 +43,11 @@ type coinSellerLedgerDTO struct {
Direction string `json:"direction"`
AvailableDelta int64 `json:"availableDelta"`
SellerBalanceAfter int64 `json:"sellerBalanceAfter"`
StockType string `json:"stockType,omitempty"`
PaidCurrencyCode string `json:"paidCurrencyCode,omitempty"`
PaidAmountMicro int64 `json:"paidAmountMicro,omitempty"`
TransferUSDMinor int64 `json:"transferUsdMinor,omitempty"`
Reason string `json:"reason,omitempty"`
CounterpartyUserID string `json:"counterpartyUserId"`
OperatorUserID string `json:"operatorUserId"`
Operator coinAdjustmentOperatorDTO `json:"operator"`

View File

@ -13,6 +13,7 @@ import (
"time"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/modules/shared"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
@ -166,7 +167,7 @@ func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, qu
}
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
_ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "转账金额", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"})
_ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "金币变动", "转账USD金额", "USDT数量", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"})
for _, item := range items {
operatorName, operatorID := coinSellerLedgerOperatorExportFields(item)
_ = writer.Write([]string{
@ -175,6 +176,8 @@ func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, qu
item.Seller.UserID,
coinSellerLedgerLabel(item),
strconv.FormatInt(signedCoinSellerLedgerAmount(item), 10),
formatUSDMinorForCSV(item.TransferUSDMinor),
formatUSDTMicroForCSV(item.PaidAmountMicro),
item.Receiver.Username,
item.Receiver.DisplayUserID,
item.Receiver.UserID,
@ -275,6 +278,11 @@ func (s *Service) listCoinSellerLedger(ctx context.Context, appCode string, quer
return nil, 0, err
}
operatorUserID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
item.StockType = firstNonEmpty(metadataString(metadata, "stock_type"), metadataString(metadata, "stockType"))
item.PaidCurrencyCode = firstNonEmpty(metadataString(metadata, "paid_currency_code"), metadataString(metadata, "paidCurrencyCode"))
item.PaidAmountMicro = metadataInt64(metadata, "paid_amount_micro", "paidAmountMicro")
item.TransferUSDMinor = metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor", "transfer_usd_minor", "transferUsdMinor")
item.Reason = metadataString(metadata, "reason")
// 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id老数据没有 metadata 时再用 counterparty_user_id
// 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。
receiverUserID := coinSellerLedgerReceiverUserID(item.BizType, sellerUserID, counterpartyUserID, metadata)
@ -442,20 +450,20 @@ func (s *Service) LookupCoinAdjustmentTarget(ctx context.Context, appCode string
if s == nil || s.userDB == nil {
return coinLedgerUserDTO{}, fmt.Errorf("user mysql is not configured")
}
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
row := s.userDB.QueryRowContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ?
AND (CAST(user_id AS CHAR) = ? OR current_display_user_id = ?)
SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appCode, keyword, keyword, keyword, keyword,
args...,
)
var profile userProfile
if err := row.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
@ -545,22 +553,15 @@ func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword
return nil, true, fmt.Errorf("user mysql is not configured")
}
// 用户列筛选同时支持长 ID、当前短 ID 和昵称;数字关键字直接加入 user_id避免用户资料缺失时查不到账务事实。
args := []any{appCode}
where := "WHERE app_code = ? AND (current_display_user_id = ?"
args = append(args, keyword)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
where += " OR user_id = ?"
args = append(args, numeric)
}
where += " OR username LIKE ? ESCAPE '\\\\')"
args = append(args, "%"+escapeLike(keyword)+"%")
// 用户列筛选同时支持长 ID、默认短号、当前短号、active 靓号和昵称;数字关键字直接加入 user_id避免用户资料缺失时查不到账务事实。
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli())
args := append([]any{appCode}, matchArgs...)
rows, err := s.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
`+where+`
ORDER BY updated_at_ms DESC, user_id DESC
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...,
)
@ -595,24 +596,16 @@ func (s *Service) resolveCoinSellerFilter(ctx context.Context, appCode string, q
return nil, true, fmt.Errorf("user mysql is not configured")
}
// 币商关键字只命中 coin_seller_profiles 里的币商本人LEFT JOIN users 只用于短 ID 和昵称补充;
// 币商关键字只命中 coin_seller_profiles 里的币商本人LEFT JOIN users 只用于短 ID、active 靓号和昵称补充;
// 这里故意不搜索 receiver/counterparty避免“收款用户命中”把其他币商的流水带进当前币商筛选结果。
args := []any{appCode}
where := "WHERE csp.app_code = ? AND (u.current_display_user_id = ?"
args = append(args, keyword)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
// 数字关键字按币商长 ID 精确匹配,不做 LIKE防止短数字造成过宽的 user_id 扫描。
where += " OR csp.user_id = ?"
args = append(args, numeric)
}
where += " OR u.username LIKE ? ESCAPE '\\\\')"
args = append(args, "%"+escapeLike(keyword)+"%")
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "csp.user_id", keyword, time.Now().UnixMilli())
args := append([]any{appCode}, matchArgs...)
rows, err := s.userDB.QueryContext(ctx, `
SELECT csp.user_id
FROM coin_seller_profiles csp
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
`+where+`
WHERE csp.app_code = ? AND `+matchSQL+`
ORDER BY csp.updated_at_ms DESC, csp.user_id DESC
LIMIT 1000`,
args...,
@ -785,16 +778,17 @@ func normalizeCoinSellerLedgerQuery(query coinSellerLedgerQuery) coinSellerLedge
func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
switch strings.TrimSpace(ledgerType) {
case "":
// 空类型表示“全部币商流水”,但仍只允许当前产品定义的三个类型口径,不能把其他币商库存内部账带出来。
// 空类型表示“全部币商流水”,但仍只允许当前产品定义的公开币商库存口径,不能把其他内部账带出来。
return []string{
coinSellerStockPurchaseBizType,
coinSellerCoinCompensationBizType,
coinSellerTransferBizType,
salaryTransferToCoinSellerBizType,
coinManualCreditBizType,
}, nil
case coinSellerLedgerTypeAdminStockCredit:
// 后台入账是运营口径,底层包含正常进货和金币补偿两种账务 biz_type
return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType}, nil
// 后台操作是运营口径,底层包含 USDT 进货、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN
return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil
case coinSellerLedgerTypeSellerTransfer:
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
return []string{coinSellerTransferBizType}, nil
@ -810,6 +804,8 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
switch bizType {
case coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType:
return coinSellerLedgerTypeAdminStockCredit
case coinManualCreditBizType:
return coinSellerLedgerTypeAdminStockCredit
case coinSellerTransferBizType:
return coinSellerLedgerTypeSellerTransfer
case salaryTransferToCoinSellerBizType:
@ -820,26 +816,30 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
}
func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
switch item.BizType {
case coinSellerStockPurchaseBizType:
return "USDT进货"
case coinSellerCoinCompensationBizType:
return "金币补偿"
case coinManualCreditBizType:
if item.AvailableDelta < 0 {
return "金币扣除"
}
return "金币增加"
case coinSellerTransferBizType:
return "币商转用户"
case salaryTransferToCoinSellerBizType:
return "工资转币商"
}
switch item.LedgerType {
case coinSellerLedgerTypeAdminStockCredit:
return "后台入账"
return "后台操作"
case coinSellerLedgerTypeSellerTransfer:
return "币商转用户"
case coinSellerLedgerTypeSalaryTransferIncome:
return "工资转币商"
default:
switch item.BizType {
case coinSellerStockPurchaseBizType:
return "币商进货"
case coinSellerCoinCompensationBizType:
return "金币补偿"
case coinSellerTransferBizType:
return "币商转用户"
case salaryTransferToCoinSellerBizType:
return "工资转币商"
default:
return firstNonEmpty(item.LedgerType, item.BizType, "-")
}
return firstNonEmpty(item.LedgerType, item.BizType, "-")
}
}
@ -850,6 +850,21 @@ func signedCoinSellerLedgerAmount(item coinSellerLedgerDTO) int64 {
return absInt64(item.Amount)
}
func formatUSDMinorForCSV(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatFloat(float64(value)/100, 'f', 2, 64)
}
func formatUSDTMicroForCSV(value int64) string {
if value <= 0 {
return ""
}
formatted := strconv.FormatFloat(float64(value)/1_000_000, 'f', 6, 64)
return strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
}
func coinSellerLedgerOperatorExportFields(item coinSellerLedgerDTO) (string, string) {
if item.LedgerType != coinSellerLedgerTypeAdminStockCredit {
return "", ""

View File

@ -51,10 +51,10 @@ func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) {
if err != nil {
t.Fatalf("coin seller ledger where failed: %v", err)
}
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?) AND e.user_id IN (?,?)"; where != want {
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?) AND e.user_id IN (?,?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != int64(3001) || args[5] != int64(3002) {
if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinManualCreditBizType || args[5] != int64(3001) || args[6] != int64(3002) {
t.Fatalf("args mismatch: %#v", args)
}
}
@ -64,10 +64,10 @@ func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) {
if err != nil {
t.Fatalf("coin seller ledger where failed: %v", err)
}
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?)"; where != want {
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?)"; where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType {
if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType || args[6] != coinManualCreditBizType {
t.Fatalf("args mismatch: %#v", args)
}
}
@ -110,6 +110,33 @@ func TestCoinSellerLedgerOperatorExportFieldsOnlyForAdminStock(t *testing.T) {
}
}
func TestCoinSellerLedgerLabelUsesConcreteStockAndDebitType(t *testing.T) {
cases := []struct {
name string
item coinSellerLedgerDTO
want string
}{
{name: "usdt purchase", item: coinSellerLedgerDTO{BizType: coinSellerStockPurchaseBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "USDT进货"},
{name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"},
{name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"},
{name: "manual increase", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: 100}, want: "金币增加"},
}
for _, tc := range cases {
if got := coinSellerLedgerLabel(tc.item); got != tc.want {
t.Fatalf("%s label mismatch: got %q want %q", tc.name, got, tc.want)
}
}
}
func TestCoinSellerLedgerCSVAmountFormatters(t *testing.T) {
if got := formatUSDMinorForCSV(50); got != "0.50" {
t.Fatalf("usd minor format mismatch: %q", got)
}
if got := formatUSDTMicroForCSV(1_230_000); got != "1.23" {
t.Fatalf("usdt micro format mismatch: %q", got)
}
}
func TestNormalizeCoinSellerLedgerQueryCapsPageSize(t *testing.T) {
query := normalizeCoinSellerLedgerQuery(coinSellerLedgerQuery{Page: -1, PageSize: 1000, SellerKeyword: " 164425 ", LedgerType: " seller_transfer "})
if query.Page != 1 || query.PageSize != 100 || query.SellerKeyword != "164425" || query.LedgerType != coinSellerLedgerTypeSellerTransfer {

View File

@ -56,7 +56,7 @@ type grantDTO struct {
GrantID string `json:"grant_id"`
AppCode string `json:"app_code"`
CycleKey string `json:"cycle_key"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
EventID string `json:"event_id"`
TransactionID string `json:"transaction_id"`
@ -79,7 +79,7 @@ type grantDTO struct {
}
type userDTO struct {
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
@ -181,24 +181,20 @@ func (h *Handler) resolveGrantUserID(ctx context.Context, keyword string) (int64
}
// keyword 支持真实 user_id、短 ID 和昵称模糊匹配;排序优先精确 ID避免昵称碰撞查到错误用户。
appCode := appctx.FromContext(ctx)
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
args...,
)
if err != nil {
return 0, false, false

View File

@ -5,6 +5,8 @@ import (
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
)
@ -13,8 +15,8 @@ type Handler struct {
service *DashboardService
}
func New(store *repository.Store, cfg config.Config) *Handler {
return &Handler{service: NewService(store, cfg)}
func New(store *repository.Store, cfg config.Config, user userclient.Client) *Handler {
return &Handler{service: NewService(store, cfg, user)}
}
func (h *Handler) DashboardOverview(c *gin.Context) {
@ -43,6 +45,23 @@ func (h *Handler) StatisticsOverview(c *gin.Context) {
response.OK(c, overview)
}
func (h *Handler) SelfGameStatisticsOverview(c *gin.Context) {
overview, err := h.service.SelfGameStatisticsOverview(c.Request.Context(), SelfGameStatisticsQuery{
AppCode: c.DefaultQuery("app_code", "lalu"),
RequestID: middleware.CurrentRequestID(c),
StartMS: parseInt64(c.Query("start_ms")),
EndMS: parseInt64(c.Query("end_ms")),
RegionID: parseInt64(c.Query("region_id")),
CountryID: parseInt64(c.Query("country_id")),
GameID: c.Query("game_id"),
})
if err != nil {
response.ServerError(c, "获取自研游戏统计失败")
return
}
response.OK(c, overview)
}
func parseInt64(value string) int64 {
parsed, _ := strconv.ParseInt(value, 10, 64)
return parsed

View File

@ -9,4 +9,5 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
protected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview)
}

View File

@ -7,8 +7,10 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/repository"
)
@ -16,6 +18,7 @@ type DashboardService struct {
store *repository.Store
cfg config.Config
httpClient *http.Client
user userclient.Client
}
type StatisticsQuery struct {
@ -28,11 +31,22 @@ type StatisticsQuery struct {
CountryID int64
}
func NewService(store *repository.Store, cfg config.Config) *DashboardService {
type SelfGameStatisticsQuery struct {
AppCode string
RequestID string
StartMS int64
EndMS int64
RegionID int64
CountryID int64
GameID string
}
func NewService(store *repository.Store, cfg config.Config, user userclient.Client) *DashboardService {
return &DashboardService{
store: store,
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.StatisticsService.RequestTimeout},
user: user,
}
}
@ -41,7 +55,6 @@ func (s *DashboardService) Overview() (*repository.DashboardOverview, error) {
}
func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) {
baseURL := s.cfg.StatisticsService.BaseURL + "/internal/v1/statistics/overview"
values := url.Values{}
values.Set("app_code", query.AppCode)
if query.StartMS > 0 {
@ -62,6 +75,39 @@ func (s *DashboardService) StatisticsOverview(ctx context.Context, query Statist
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
return s.statisticsGET(ctx, "/internal/v1/statistics/overview", values)
}
func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query SelfGameStatisticsQuery) (map[string]any, error) {
values := url.Values{}
values.Set("app_code", query.AppCode)
if query.StartMS > 0 {
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
}
if query.EndMS > 0 {
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
}
if query.RegionID > 0 {
values.Set("region_id", strconv.FormatInt(query.RegionID, 10))
}
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
if gameID := strings.TrimSpace(query.GameID); gameID != "" && gameID != "all" {
values.Set("game_id", gameID)
}
// 数据大屏只通过 statistics-service 读取自研游戏聚合指标,避免 admin 服务绕过聚合层去扫业务明细表。
overview, err := s.statisticsGET(ctx, "/internal/v1/statistics/self-games/overview", values)
if err != nil {
return nil, err
}
// 高风险用户只对 statistics-service 已返回的 topN user_id 做批量资料补全;头像、昵称、短 ID 不进入统计库,避免聚合链路反查用户表。
_ = s.enrichSelfGameRiskUsers(ctx, query, overview)
return overview, nil
}
func (s *DashboardService) statisticsGET(ctx context.Context, path string, values url.Values) (map[string]any, error) {
baseURL := s.cfg.StatisticsService.BaseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+values.Encode(), nil)
if err != nil {
return nil, err
@ -75,8 +121,85 @@ func (s *DashboardService) StatisticsOverview(ctx context.Context, query Statist
return nil, fmt.Errorf("statistics service returned status %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
decoder := json.NewDecoder(resp.Body)
decoder.UseNumber()
if err := decoder.Decode(&out); err != nil {
return nil, err
}
return out, nil
}
func (s *DashboardService) enrichSelfGameRiskUsers(ctx context.Context, query SelfGameStatisticsQuery, overview map[string]any) error {
if s.user == nil || overview == nil {
return nil
}
rows, ok := overview["user_risk"].([]any)
if !ok || len(rows) == 0 {
return nil
}
userIDs := make([]int64, 0, len(rows))
seen := map[int64]struct{}{}
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
userID := anyInt64(row["user_id"])
if userID <= 0 {
continue
}
// user_id 是 16 位以上业务 IDadmin 层统一改成字符串透给 H5避免浏览器 JSON number 精度丢失。
row["user_id"] = strconv.FormatInt(userID, 10)
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
userIDs = append(userIDs, userID)
}
if len(userIDs) == 0 {
return nil
}
users, err := s.user.BatchGetUsers(ctx, userclient.BatchGetUsersRequest{
RequestID: query.RequestID,
Caller: "admin-server",
UserIDs: userIDs,
})
if err != nil {
return err
}
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
user := users[anyInt64(row["user_id"])]
if user == nil {
continue
}
row["avatar"] = user.Avatar
row["nickname"] = user.Username
row["username"] = user.Username
row["display_user_id"] = user.DisplayUserID
row["short_id"] = user.DisplayUserID
}
return nil
}
func anyInt64(value any) int64 {
switch typed := value.(type) {
case int64:
return typed
case int:
return int64(typed)
case float64:
return int64(typed)
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
return 0
}
}

View File

@ -61,7 +61,7 @@ type claimDTO struct {
EventID string `json:"event_id"`
TransactionID string `json:"transaction_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
TierID int64 `json:"tier_id"`
TierCode string `json:"tier_code"`
@ -83,7 +83,7 @@ type claimDTO struct {
}
type userDTO struct {
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
@ -185,24 +185,20 @@ func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64
return 0, false, true
}
appCode := appctx.FromContext(ctx)
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
args...,
)
if err != nil {
return 0, false, false

View File

@ -2,6 +2,7 @@ package gamemanagement
import (
"strconv"
"strings"
gamev1 "hyapp.local/api/proto/game/v1"
)
@ -72,6 +73,7 @@ type diceStakeDTO struct {
type dicePoolAdjustmentDTO struct {
AdjustmentID string `json:"adjustmentId"`
GameID string `json:"gameId"`
StakeCoin int64 `json:"stakeCoin"`
AmountCoin int64 `json:"amountCoin"`
Direction string `json:"direction"`
Reason string `json:"reason"`
@ -79,6 +81,46 @@ type dicePoolAdjustmentDTO struct {
CreatedAtMS int64 `json:"createdAtMs"`
}
type selfGameStakePoolDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
StakeCoin int64 `json:"stakeCoin"`
BalanceCoin int64 `json:"balanceCoin"`
ReservedCoin int64 `json:"reservedCoin"`
AvailableCoin int64 `json:"availableCoin"`
TargetBalanceCoin int64 `json:"targetBalanceCoin"`
SafeBalanceCoin int64 `json:"safeBalanceCoin"`
WarningBalanceCoin int64 `json:"warningBalanceCoin"`
DangerBalanceCoin int64 `json:"dangerBalanceCoin"`
Status string `json:"status"`
PoolLevel string `json:"poolLevel"`
RequiredPoolCoin int64 `json:"requiredPoolCoin"`
HumanWinCapacity int64 `json:"humanWinCapacity"`
BotMatchEnabled bool `json:"botMatchEnabled"`
BlackReason string `json:"blackReason"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type selfGameNewUserPolicyDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
Enabled bool `json:"enabled"`
ProtectionRounds int32 `json:"protectionRounds"`
ProtectionHours int32 `json:"protectionHours"`
MaxStakeCoin int64 `json:"maxStakeCoin"`
LifetimeSubsidyQuotaCoin int64 `json:"lifetimeSubsidyQuotaCoin"`
Day1SubsidyQuotaCoin int64 `json:"day1SubsidyQuotaCoin"`
SingleRoundSubsidyCapCoin int64 `json:"singleRoundSubsidyCapCoin"`
StrategyLevel string `json:"strategyLevel"`
LoseStreakProtectionEnabled bool `json:"loseStreakProtectionEnabled"`
LoseStreakTrigger int32 `json:"loseStreakTrigger"`
NormalPhaseTargetWinRatePercent int32 `json:"normalPhaseTargetWinRatePercent"`
BlackPoolRobotForceWinEnabled bool `json:"blackPoolRobotForceWinEnabled"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type diceRobotDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
@ -228,6 +270,7 @@ func dicePoolAdjustmentFromProto(item *gamev1.DicePoolAdjustment) dicePoolAdjust
return dicePoolAdjustmentDTO{
AdjustmentID: item.GetAdjustmentId(),
GameID: item.GetGameId(),
StakeCoin: item.GetStakeCoin(),
AmountCoin: item.GetAmountCoin(),
Direction: item.GetDirection(),
Reason: item.GetReason(),
@ -236,6 +279,56 @@ func dicePoolAdjustmentFromProto(item *gamev1.DicePoolAdjustment) dicePoolAdjust
}
}
func selfGameStakePoolFromProto(item *gamev1.SelfGameStakePool) selfGameStakePoolDTO {
if item == nil {
return selfGameStakePoolDTO{}
}
return selfGameStakePoolDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
StakeCoin: item.GetStakeCoin(),
BalanceCoin: item.GetBalanceCoin(),
ReservedCoin: item.GetReservedCoin(),
AvailableCoin: item.GetAvailableCoin(),
TargetBalanceCoin: item.GetTargetBalanceCoin(),
SafeBalanceCoin: item.GetSafeBalanceCoin(),
WarningBalanceCoin: item.GetWarningBalanceCoin(),
DangerBalanceCoin: item.GetDangerBalanceCoin(),
Status: item.GetStatus(),
PoolLevel: item.GetPoolLevel(),
RequiredPoolCoin: item.GetRequiredPoolCoin(),
HumanWinCapacity: item.GetHumanWinCapacity(),
BotMatchEnabled: item.GetBotMatchEnabled(),
BlackReason: item.GetBlackReason(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func selfGameNewUserPolicyFromProto(item *gamev1.SelfGameNewUserPolicy) selfGameNewUserPolicyDTO {
if item == nil {
return selfGameNewUserPolicyDTO{}
}
return selfGameNewUserPolicyDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
Enabled: item.GetEnabled(),
ProtectionRounds: item.GetProtectionRounds(),
ProtectionHours: item.GetProtectionHours(),
MaxStakeCoin: item.GetMaxStakeCoin(),
LifetimeSubsidyQuotaCoin: item.GetLifetimeSubsidyQuotaCoin(),
Day1SubsidyQuotaCoin: item.GetDay1SubsidyQuotaCoin(),
SingleRoundSubsidyCapCoin: item.GetSingleRoundSubsidyCapCoin(),
StrategyLevel: item.GetStrategyLevel(),
LoseStreakProtectionEnabled: item.GetLoseStreakProtectionEnabled(),
LoseStreakTrigger: item.GetLoseStreakTrigger(),
NormalPhaseTargetWinRatePercent: item.GetNormalPhaseTargetWinRatePercent(),
BlackPoolRobotForceWinEnabled: item.GetBlackPoolRobotForceWinEnabled(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func diceRobotFromProto(item *gamev1.DiceRobot) diceRobotDTO {
if item == nil {
return diceRobotDTO{}
@ -260,7 +353,7 @@ func roomRPSConfigFromProto(item *gamev1.RoomRPSConfig) roomRPSConfigDTO {
for _, gift := range item.GetStakeGifts() {
// 礼物详情由 game-service 结合礼物配置补齐admin-server 只保持展示字段和 ID 双写,不重新查礼物表。
gifts = append(gifts, roomRPSStakeGiftDTO{
GiftID: strconv.FormatInt(gift.GetGiftId(), 10),
GiftID: roomRPSStakeGiftIDFromProto(gift),
GiftIDNumber: gift.GetGiftId(),
GiftName: gift.GetGiftName(),
GiftIconURL: gift.GetGiftIconUrl(),
@ -294,7 +387,7 @@ func roomRPSChallengeFromProto(item *gamev1.RoomRPSChallenge) roomRPSChallengeDT
RoomID: item.GetRoomId(),
RegionID: item.GetRegionId(),
Status: item.GetStatus(),
StakeGiftID: strconv.FormatInt(item.GetStakeGiftId(), 10),
StakeGiftID: roomRPSChallengeStakeGiftIDFromProto(item),
StakeGiftIDNumber: item.GetStakeGiftId(),
StakeCoin: item.GetStakeCoin(),
Initiator: roomRPSPlayerFromProto(item.GetInitiator()),
@ -312,6 +405,32 @@ func roomRPSChallengeFromProto(item *gamev1.RoomRPSChallenge) roomRPSChallengeDT
}
}
func roomRPSStakeGiftIDFromProto(gift *gamev1.RoomRPSStakeGift) string {
if gift == nil {
return ""
}
if text := strings.TrimSpace(gift.GetGiftIdText()); text != "" {
return text
}
if gift.GetGiftId() > 0 {
return strconv.FormatInt(gift.GetGiftId(), 10)
}
return ""
}
func roomRPSChallengeStakeGiftIDFromProto(item *gamev1.RoomRPSChallenge) string {
if item == nil {
return ""
}
if text := strings.TrimSpace(item.GetStakeGiftIdText()); text != "" {
return text
}
if item.GetStakeGiftId() > 0 {
return strconv.FormatInt(item.GetStakeGiftId(), 10)
}
return ""
}
func roomRPSPlayerFromProto(item *gamev1.RoomRPSPlayer) roomRPSPlayerDTO {
if item == nil {
return roomRPSPlayerDTO{}

View File

@ -23,15 +23,51 @@ func TestPlatformFromProtoReturnsCallbackSecret(t *testing.T) {
}
}
func TestSelfGameNewUserPolicyKeepsNormalPhaseTargetWinRate(t *testing.T) {
proto := (selfGameNewUserPolicyRequest{
Enabled: true,
ProtectionRounds: 30,
ProtectionHours: 168,
MaxStakeCoin: 5000,
LifetimeSubsidyQuotaCoin: 30000,
Day1SubsidyQuotaCoin: 15000,
SingleRoundSubsidyCapCoin: 5000,
StrategyLevel: "soft_boost",
LoseStreakProtectionEnabled: true,
LoseStreakTrigger: 2,
NormalPhaseTargetWinRatePercent: 57,
BlackPoolRobotForceWinEnabled: true,
}).toProto("dice")
if proto.GetNormalPhaseTargetWinRatePercent() != 57 {
t.Fatalf("request normal phase target = %d, want 57", proto.GetNormalPhaseTargetWinRatePercent())
}
if !proto.GetBlackPoolRobotForceWinEnabled() {
t.Fatal("request black pool robot force win switch was not preserved")
}
dto := selfGameNewUserPolicyFromProto(&gamev1.SelfGameNewUserPolicy{
GameId: "dice",
NormalPhaseTargetWinRatePercent: 57,
BlackPoolRobotForceWinEnabled: true,
})
if dto.NormalPhaseTargetWinRatePercent != 57 {
t.Fatalf("dto normal phase target = %d, want 57", dto.NormalPhaseTargetWinRatePercent)
}
if !dto.BlackPoolRobotForceWinEnabled {
t.Fatal("dto black pool robot force win switch was not preserved")
}
}
func TestRoomRPSConfigRequestRequiresFourGiftTiers(t *testing.T) {
_, err := (roomRPSConfigRequest{
Status: "active",
ChallengeTimeoutMS: 600000,
RevealCountdownMS: 3000,
StakeGifts: []roomRPSStakeGiftReq{
{GiftID: 1001, Enabled: true, SortOrder: 1},
{GiftID: 1002, Enabled: true, SortOrder: 2},
{GiftID: 1003, Enabled: true, SortOrder: 3},
{GiftID: "1001", Enabled: true, SortOrder: 1},
{GiftID: "1002", Enabled: true, SortOrder: 2},
{GiftID: "1003", Enabled: true, SortOrder: 3},
},
}).toProto()
@ -43,10 +79,10 @@ func TestRoomRPSConfigRequestRequiresFourGiftTiers(t *testing.T) {
func TestRoomRPSConfigRequestDefaultsOpenConfig(t *testing.T) {
got, err := (roomRPSConfigRequest{
StakeGifts: []roomRPSStakeGiftReq{
{GiftID: 1001, Enabled: true},
{GiftID: 1002, Enabled: true},
{GiftID: 1003, Enabled: true},
{GiftID: 1004, Enabled: true},
{GiftID: "1001", Enabled: true},
{GiftID: "1002", Enabled: true},
{GiftID: "1003", Enabled: true},
{GiftID: "1004", Enabled: true},
},
}).toProto()
if err != nil {
@ -60,6 +96,23 @@ func TestRoomRPSConfigRequestDefaultsOpenConfig(t *testing.T) {
}
}
func TestRoomRPSConfigRequestAcceptsStringGiftIDs(t *testing.T) {
got, err := (roomRPSConfigRequest{
StakeGifts: []roomRPSStakeGiftReq{
{GiftID: "Gifi-3", Enabled: true, SortOrder: 1},
{GiftID: "Gifi-2", Enabled: true, SortOrder: 2},
{GiftID: "Gifi-1", Enabled: true, SortOrder: 3},
{GiftID: "lw1", Enabled: true, SortOrder: 4},
},
}).toProto()
if err != nil {
t.Fatalf("toProto should accept string gift ids: %v", err)
}
if got.GetStakeGifts()[0].GetGiftIdText() != "Gifi-3" || got.GetStakeGifts()[0].GetGiftId() != 0 {
t.Fatalf("string gift id should stay in text field without fake numeric id: %+v", got.GetStakeGifts()[0])
}
}
func TestRoomRPSChallengeFromProtoKeepsLargeIDsAsStrings(t *testing.T) {
got := roomRPSChallengeFromProto(&gamev1.RoomRPSChallenge{
ChallengeId: "rps-1",

View File

@ -14,17 +14,17 @@ import (
)
type Handler struct {
game gameclient.Client
user userclient.Client
audit shared.OperationLogger
robotAvatar robotAvatarConfig
game gameclient.Client
user userclient.Client
audit shared.OperationLogger
robotProfiles RobotProfileSource
}
// Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。
type Option func(*Handler)
func New(game gameclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *Handler {
h := &Handler{game: game, user: user, audit: audit, robotAvatar: defaultRobotAvatarConfig()}
h := &Handler{game: game, user: user, audit: audit}
for _, option := range options {
if option != nil {
option(h)

View File

@ -2,6 +2,7 @@ package gamemanagement
import (
"fmt"
"strconv"
"strings"
"time"
@ -71,6 +72,29 @@ type dicePoolAdjustRequest struct {
Reason string `json:"reason"`
}
type selfGameStakePoolRequest struct {
TargetBalanceCoin int64 `json:"targetBalanceCoin"`
SafeBalanceCoin int64 `json:"safeBalanceCoin"`
WarningBalanceCoin int64 `json:"warningBalanceCoin"`
DangerBalanceCoin int64 `json:"dangerBalanceCoin"`
Status string `json:"status"`
}
type selfGameNewUserPolicyRequest struct {
Enabled bool `json:"enabled"`
ProtectionRounds int32 `json:"protectionRounds"`
ProtectionHours int32 `json:"protectionHours"`
MaxStakeCoin int64 `json:"maxStakeCoin"`
LifetimeSubsidyQuotaCoin int64 `json:"lifetimeSubsidyQuotaCoin"`
Day1SubsidyQuotaCoin int64 `json:"day1SubsidyQuotaCoin"`
SingleRoundSubsidyCapCoin int64 `json:"singleRoundSubsidyCapCoin"`
StrategyLevel string `json:"strategyLevel"`
LoseStreakProtectionEnabled bool `json:"loseStreakProtectionEnabled"`
LoseStreakTrigger int32 `json:"loseStreakTrigger"`
NormalPhaseTargetWinRatePercent int32 `json:"normalPhaseTargetWinRatePercent"`
BlackPoolRobotForceWinEnabled bool `json:"blackPoolRobotForceWinEnabled"`
}
type diceGenerateRobotsRequest struct {
Count int32 `json:"count"`
NicknamePrefix string `json:"nicknamePrefix"`
@ -92,9 +116,9 @@ type roomRPSConfigRequest struct {
}
type roomRPSStakeGiftReq struct {
GiftID int64 `json:"giftId"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
GiftID string `json:"giftId"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sortOrder"`
}
func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
@ -158,6 +182,36 @@ func (r diceConfigRequest) toProto(gameID string) *gamev1.DiceConfig {
}
}
func (r selfGameNewUserPolicyRequest) toProto(gameID string) *gamev1.SelfGameNewUserPolicy {
return &gamev1.SelfGameNewUserPolicy{
GameId: strings.TrimSpace(gameID),
Enabled: r.Enabled,
ProtectionRounds: r.ProtectionRounds,
ProtectionHours: r.ProtectionHours,
MaxStakeCoin: r.MaxStakeCoin,
LifetimeSubsidyQuotaCoin: r.LifetimeSubsidyQuotaCoin,
Day1SubsidyQuotaCoin: r.Day1SubsidyQuotaCoin,
SingleRoundSubsidyCapCoin: r.SingleRoundSubsidyCapCoin,
StrategyLevel: strings.TrimSpace(r.StrategyLevel),
LoseStreakProtectionEnabled: r.LoseStreakProtectionEnabled,
LoseStreakTrigger: r.LoseStreakTrigger,
NormalPhaseTargetWinRatePercent: r.NormalPhaseTargetWinRatePercent,
BlackPoolRobotForceWinEnabled: r.BlackPoolRobotForceWinEnabled,
}
}
func (r selfGameStakePoolRequest) toProto(gameID string, stakeCoin int64) *gamev1.SelfGameStakePool {
return &gamev1.SelfGameStakePool{
GameId: strings.TrimSpace(gameID),
StakeCoin: stakeCoin,
TargetBalanceCoin: r.TargetBalanceCoin,
SafeBalanceCoin: r.SafeBalanceCoin,
WarningBalanceCoin: r.WarningBalanceCoin,
DangerBalanceCoin: r.DangerBalanceCoin,
Status: strings.TrimSpace(r.Status),
}
}
func (r roomRPSConfigRequest) toProto() (*gamev1.RoomRPSConfig, error) {
status := strings.TrimSpace(r.Status)
if status == "" {
@ -179,26 +233,28 @@ func (r roomRPSConfigRequest) toProto() (*gamev1.RoomRPSConfig, error) {
return nil, fmt.Errorf("房内猜拳礼物档位必须配置 4 档")
}
gifts := make([]*gamev1.RoomRPSStakeGift, 0, len(r.StakeGifts))
seen := make(map[int64]struct{}, len(r.StakeGifts))
seen := make(map[string]struct{}, len(r.StakeGifts))
for index, gift := range r.StakeGifts {
if gift.GiftID <= 0 {
// gift_id 必须是后台真实礼物 ID0 不能表示“空档位”,空档位应该通过 enabled=false 表达。
giftID := strings.TrimSpace(gift.GiftID)
if giftID == "" {
// gift_id 是后台礼物表的真实主键,当前礼物体系允许字符串;空值不能表示“空档位”,空档位应该通过 enabled=false 表达。
return nil, fmt.Errorf("第 %d 档礼物 ID 不正确", index+1)
}
if _, ok := seen[gift.GiftID]; ok {
if _, ok := seen[giftID]; ok {
// 同一个礼物不能重复配置成多档,否则结算金额和排序展示会变成同一个 gift_id 的多份事实。
return nil, fmt.Errorf("第 %d 档礼物 ID 重复", index+1)
}
seen[gift.GiftID] = struct{}{}
seen[giftID] = struct{}{}
sortOrder := gift.SortOrder
if sortOrder <= 0 {
// 未显式给排序时按提交顺序落库,保证前端拖拽顺序和运行时展示顺序一致。
sortOrder = int32(index + 1)
}
gifts = append(gifts, &gamev1.RoomRPSStakeGift{
GiftId: gift.GiftID,
Enabled: gift.Enabled,
SortOrder: sortOrder,
GiftId: legacyRoomRPSGiftID(giftID),
GiftIdText: giftID,
Enabled: gift.Enabled,
SortOrder: sortOrder,
})
}
return &gamev1.RoomRPSConfig{
@ -211,6 +267,14 @@ func (r roomRPSConfigRequest) toProto() (*gamev1.RoomRPSConfig, error) {
}, nil
}
func legacyRoomRPSGiftID(giftID string) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64)
if err != nil || value <= 0 {
return 0
}
return value
}
func compactTags(tags []string) []string {
out := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))

View File

@ -1,167 +0,0 @@
package gamemanagement
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"hyapp-admin-server/internal/platform/idgen"
)
const (
robotAvatarDownloadTimeout = 10 * time.Second
robotAvatarMaxBytes = 5 << 20
robotAvatarSniffBytes = 512
)
// ObjectUploader 是游戏管理模块需要的对象存储最小能力,实际生产实现由后台 COS 平台层提供。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
type robotAvatarConfig struct {
uploader ObjectUploader
objectPrefix string
httpClient *http.Client
}
func defaultRobotAvatarConfig() robotAvatarConfig {
return robotAvatarConfig{
objectPrefix: "admin",
httpClient: &http.Client{Timeout: robotAvatarDownloadTimeout},
}
}
// WithRobotAvatarUploader 注入后台已装配的 COS 上传器,机器人创建只拿这个薄接口,不直接依赖 COS SDK。
func WithRobotAvatarUploader(uploader ObjectUploader, objectPrefix string) Option {
return func(h *Handler) {
h.robotAvatar.uploader = uploader
h.robotAvatar.objectPrefix = cleanRobotAvatarObjectPrefix(objectPrefix)
}
}
// WithRobotAvatarHTTPClient 只给测试替换下载客户端;生产默认使用带超时的标准 HTTP client。
func WithRobotAvatarHTTPClient(client *http.Client) Option {
return func(h *Handler) {
if client != nil {
h.robotAvatar.httpClient = client
}
}
}
func (h *Handler) uploadRobotAvatarFromSource(ctx context.Context, actorID uint, sourceURL string) (string, error) {
sourceURL = strings.TrimSpace(sourceURL)
if sourceURL == "" {
return "", fmt.Errorf("robot avatar source url is empty")
}
if h.robotAvatar.uploader == nil {
return "", fmt.Errorf("robot avatar cos uploader is not configured")
}
data, contentType, err := h.downloadRobotAvatar(ctx, sourceURL)
if err != nil {
return "", err
}
// 机器人头像先进入后台 COS 前缀再写入用户资料App 端只依赖自有 CDN URL不直接依赖第三方头像站。
key := buildRobotAvatarObjectKey(h.robotAvatar.objectPrefix, actorID, contentType, time.Now().UTC())
return h.robotAvatar.uploader.PutObject(ctx, key, bytes.NewReader(data), int64(len(data)), contentType)
}
func (h *Handler) downloadRobotAvatar(ctx context.Context, sourceURL string) ([]byte, string, error) {
if !validRobotAvatarSourceURL(sourceURL) {
return nil, "", fmt.Errorf("robot avatar source url is invalid")
}
client := h.robotAvatar.httpClient
if client == nil {
client = &http.Client{Timeout: robotAvatarDownloadTimeout}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, "", fmt.Errorf("build robot avatar request: %w", err)
}
req.Header.Set("User-Agent", "hyapp-admin-server/robot-avatar")
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download robot avatar: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, "", fmt.Errorf("download robot avatar returned status %d", resp.StatusCode)
}
if resp.ContentLength > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar is too large")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, robotAvatarMaxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("read robot avatar: %w", err)
}
if len(data) == 0 || len(data) > robotAvatarMaxBytes {
return nil, "", fmt.Errorf("robot avatar size is invalid")
}
sniffSize := len(data)
if sniffSize > robotAvatarSniffBytes {
sniffSize = robotAvatarSniffBytes
}
contentType, ok := normalizeRobotAvatarContentType(http.DetectContentType(data[:sniffSize]), resp.Header.Get("Content-Type"))
if !ok {
return nil, "", fmt.Errorf("robot avatar content type is unsupported")
}
return data, contentType, nil
}
func validRobotAvatarSourceURL(raw string) bool {
u, err := url.ParseRequestURI(strings.TrimSpace(raw))
if err != nil {
return false
}
return (u.Scheme == "https" || u.Scheme == "http") && strings.TrimSpace(u.Host) != ""
}
func normalizeRobotAvatarContentType(values ...string) (string, bool) {
for _, value := range values {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0]))
switch contentType {
case "image/jpeg", "image/jpg":
return "image/jpeg", true
case "image/png":
return "image/png", true
case "image/webp":
return "image/webp", true
}
}
return "", false
}
func buildRobotAvatarObjectKey(objectPrefix string, actorID uint, contentType string, now time.Time) string {
extension := ".jpg"
switch contentType {
case "image/png":
extension = ".png"
case "image/webp":
extension = ".webp"
}
return fmt.Sprintf(
"%s/game-robots/avatars/%d/%s/%s%s",
cleanRobotAvatarObjectPrefix(objectPrefix),
actorID,
now.UTC().Format("20060102"),
idgen.New("avatar"),
extension,
)
}
func cleanRobotAvatarObjectPrefix(value string) string {
value = strings.Trim(strings.TrimSpace(value), "/")
if value == "" {
return "admin"
}
return value
}

View File

@ -1,133 +0,0 @@
package gamemanagement
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode"
)
type fakeRobotAvatarUploader struct {
key string
contentType string
data []byte
}
func (u *fakeRobotAvatarUploader) PutObject(_ context.Context, key string, reader io.Reader, _ int64, contentType string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
u.key = key
u.contentType = contentType
u.data = data
return "https://media.example.com/" + key, nil
}
func TestUploadRobotAvatarFromSourceUploadsDownloadedImageToCOS(t *testing.T) {
imageBytes, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")
if err != nil {
t.Fatalf("decode png: %v", err)
}
avatarServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imageBytes)
}))
defer avatarServer.Close()
uploader := &fakeRobotAvatarUploader{}
handler := New(nil, nil, nil, WithRobotAvatarUploader(uploader, "admin"), WithRobotAvatarHTTPClient(avatarServer.Client()))
got, err := handler.uploadRobotAvatarFromSource(t.Context(), 42, avatarServer.URL+"/avatar.png")
if err != nil {
t.Fatalf("uploadRobotAvatarFromSource failed: %v", err)
}
if !strings.HasPrefix(got, "https://media.example.com/admin/game-robots/avatars/42/") {
t.Fatalf("uploaded url = %q", got)
}
if uploader.contentType != "image/png" {
t.Fatalf("contentType = %q, want image/png", uploader.contentType)
}
if string(uploader.data) != string(imageBytes) {
t.Fatalf("uploaded bytes mismatch")
}
}
func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) {
// 预设池是批量创建机器人的基础数据,数量变化会直接影响前端一次创建 200 个机器人时的去重效果。
if len(defaultArabicRobotNicknames) != 100 {
t.Fatalf("arabic nickname preset count = %d, want 100", len(defaultArabicRobotNicknames))
}
if len(defaultEnglishRobotNicknames) != 100 {
t.Fatalf("english nickname preset count = %d, want 100", len(defaultEnglishRobotNicknames))
}
if len(defaultRobotAvatarSources) != 200 {
t.Fatalf("avatar preset count = %d, want 200", len(defaultRobotAvatarSources))
}
if uniqueCount(defaultRobotAvatarSources) != 200 {
t.Fatalf("avatar preset must keep 200 unique source urls")
}
englishNames := make([]string, 0, 200)
arabicNames := make([]string, 0, 200)
for index := 0; index < 200; index++ {
english := robotNickname(index, "english")
if !isPureEnglishNickname(english) {
t.Fatalf("english nickname[%d] = %q, want letters only", index, english)
}
englishNames = append(englishNames, english)
arabic := robotNickname(index, "arabic")
if !isPureArabicNickname(arabic) {
t.Fatalf("arabic nickname[%d] = %q, want arabic letters only", index, arabic)
}
arabicNames = append(arabicNames, arabic)
}
if uniqueCount(englishNames) != len(englishNames) {
t.Fatalf("english nickname presets and overflow combinations must keep 200 unique names")
}
if uniqueCount(arabicNames) != len(arabicNames) {
t.Fatalf("arabic nickname presets and overflow combinations must keep 200 unique names")
}
if got := robotNickname(100, "english"); got != "AlexJordan" {
t.Fatalf("english overflow nickname = %q, want pure english combined name", got)
}
if got := robotNickname(100, "arabic"); got != "أميرسيف" {
t.Fatalf("arabic overflow nickname = %q, want pure arabic combined name", got)
}
}
func isPureEnglishNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if (item < 'A' || item > 'Z') && (item < 'a' || item > 'z') {
return false
}
}
return true
}
func isPureArabicNickname(value string) bool {
if value == "" {
return false
}
for _, item := range value {
if !unicode.Is(unicode.Arabic, item) {
return false
}
}
return true
}
func uniqueCount(values []string) int {
seen := make(map[string]struct{}, len(values))
for _, value := range values {
seen[value] = struct{}{}
}
return len(seen)
}

View File

@ -1,494 +1,22 @@
package gamemanagement
import (
"strings"
"unicode"
)
import "strings"
const (
robotNicknameLanguageArabic = "arabic"
robotNicknameLanguageEnglish = "english"
defaultRobotGenerateCount = 20
maxRobotGenerateCount = 1000
)
var defaultRobotAvatarSources = []string{
"https://randomuser.me/api/portraits/men/0.jpg",
"https://randomuser.me/api/portraits/men/1.jpg",
"https://randomuser.me/api/portraits/men/2.jpg",
"https://randomuser.me/api/portraits/men/3.jpg",
"https://randomuser.me/api/portraits/men/4.jpg",
"https://randomuser.me/api/portraits/men/5.jpg",
"https://randomuser.me/api/portraits/men/6.jpg",
"https://randomuser.me/api/portraits/men/7.jpg",
"https://randomuser.me/api/portraits/men/8.jpg",
"https://randomuser.me/api/portraits/men/9.jpg",
"https://randomuser.me/api/portraits/men/10.jpg",
"https://randomuser.me/api/portraits/men/11.jpg",
"https://randomuser.me/api/portraits/men/12.jpg",
"https://randomuser.me/api/portraits/men/13.jpg",
"https://randomuser.me/api/portraits/men/14.jpg",
"https://randomuser.me/api/portraits/men/15.jpg",
"https://randomuser.me/api/portraits/men/16.jpg",
"https://randomuser.me/api/portraits/men/17.jpg",
"https://randomuser.me/api/portraits/men/18.jpg",
"https://randomuser.me/api/portraits/men/19.jpg",
"https://randomuser.me/api/portraits/men/20.jpg",
"https://randomuser.me/api/portraits/men/21.jpg",
"https://randomuser.me/api/portraits/men/22.jpg",
"https://randomuser.me/api/portraits/men/23.jpg",
"https://randomuser.me/api/portraits/men/24.jpg",
"https://randomuser.me/api/portraits/men/25.jpg",
"https://randomuser.me/api/portraits/men/26.jpg",
"https://randomuser.me/api/portraits/men/27.jpg",
"https://randomuser.me/api/portraits/men/28.jpg",
"https://randomuser.me/api/portraits/men/29.jpg",
"https://randomuser.me/api/portraits/men/30.jpg",
"https://randomuser.me/api/portraits/men/31.jpg",
"https://randomuser.me/api/portraits/men/32.jpg",
"https://randomuser.me/api/portraits/men/33.jpg",
"https://randomuser.me/api/portraits/men/34.jpg",
"https://randomuser.me/api/portraits/men/35.jpg",
"https://randomuser.me/api/portraits/men/36.jpg",
"https://randomuser.me/api/portraits/men/37.jpg",
"https://randomuser.me/api/portraits/men/38.jpg",
"https://randomuser.me/api/portraits/men/39.jpg",
"https://randomuser.me/api/portraits/men/40.jpg",
"https://randomuser.me/api/portraits/men/41.jpg",
"https://randomuser.me/api/portraits/men/42.jpg",
"https://randomuser.me/api/portraits/men/43.jpg",
"https://randomuser.me/api/portraits/men/44.jpg",
"https://randomuser.me/api/portraits/men/45.jpg",
"https://randomuser.me/api/portraits/men/46.jpg",
"https://randomuser.me/api/portraits/men/47.jpg",
"https://randomuser.me/api/portraits/men/48.jpg",
"https://randomuser.me/api/portraits/men/49.jpg",
"https://randomuser.me/api/portraits/men/50.jpg",
"https://randomuser.me/api/portraits/men/51.jpg",
"https://randomuser.me/api/portraits/men/52.jpg",
"https://randomuser.me/api/portraits/men/53.jpg",
"https://randomuser.me/api/portraits/men/54.jpg",
"https://randomuser.me/api/portraits/men/55.jpg",
"https://randomuser.me/api/portraits/men/56.jpg",
"https://randomuser.me/api/portraits/men/57.jpg",
"https://randomuser.me/api/portraits/men/58.jpg",
"https://randomuser.me/api/portraits/men/59.jpg",
"https://randomuser.me/api/portraits/men/60.jpg",
"https://randomuser.me/api/portraits/men/61.jpg",
"https://randomuser.me/api/portraits/men/62.jpg",
"https://randomuser.me/api/portraits/men/63.jpg",
"https://randomuser.me/api/portraits/men/64.jpg",
"https://randomuser.me/api/portraits/men/65.jpg",
"https://randomuser.me/api/portraits/men/66.jpg",
"https://randomuser.me/api/portraits/men/67.jpg",
"https://randomuser.me/api/portraits/men/68.jpg",
"https://randomuser.me/api/portraits/men/69.jpg",
"https://randomuser.me/api/portraits/men/70.jpg",
"https://randomuser.me/api/portraits/men/71.jpg",
"https://randomuser.me/api/portraits/men/72.jpg",
"https://randomuser.me/api/portraits/men/73.jpg",
"https://randomuser.me/api/portraits/men/74.jpg",
"https://randomuser.me/api/portraits/men/75.jpg",
"https://randomuser.me/api/portraits/men/76.jpg",
"https://randomuser.me/api/portraits/men/77.jpg",
"https://randomuser.me/api/portraits/men/78.jpg",
"https://randomuser.me/api/portraits/men/79.jpg",
"https://randomuser.me/api/portraits/men/80.jpg",
"https://randomuser.me/api/portraits/men/81.jpg",
"https://randomuser.me/api/portraits/men/82.jpg",
"https://randomuser.me/api/portraits/men/83.jpg",
"https://randomuser.me/api/portraits/men/84.jpg",
"https://randomuser.me/api/portraits/men/85.jpg",
"https://randomuser.me/api/portraits/men/86.jpg",
"https://randomuser.me/api/portraits/men/87.jpg",
"https://randomuser.me/api/portraits/men/88.jpg",
"https://randomuser.me/api/portraits/men/89.jpg",
"https://randomuser.me/api/portraits/men/90.jpg",
"https://randomuser.me/api/portraits/men/91.jpg",
"https://randomuser.me/api/portraits/men/92.jpg",
"https://randomuser.me/api/portraits/men/93.jpg",
"https://randomuser.me/api/portraits/men/94.jpg",
"https://randomuser.me/api/portraits/men/95.jpg",
"https://randomuser.me/api/portraits/men/96.jpg",
"https://randomuser.me/api/portraits/men/97.jpg",
"https://randomuser.me/api/portraits/men/98.jpg",
"https://randomuser.me/api/portraits/men/99.jpg",
"https://randomuser.me/api/portraits/women/0.jpg",
"https://randomuser.me/api/portraits/women/1.jpg",
"https://randomuser.me/api/portraits/women/2.jpg",
"https://randomuser.me/api/portraits/women/3.jpg",
"https://randomuser.me/api/portraits/women/4.jpg",
"https://randomuser.me/api/portraits/women/5.jpg",
"https://randomuser.me/api/portraits/women/6.jpg",
"https://randomuser.me/api/portraits/women/7.jpg",
"https://randomuser.me/api/portraits/women/8.jpg",
"https://randomuser.me/api/portraits/women/9.jpg",
"https://randomuser.me/api/portraits/women/10.jpg",
"https://randomuser.me/api/portraits/women/11.jpg",
"https://randomuser.me/api/portraits/women/12.jpg",
"https://randomuser.me/api/portraits/women/13.jpg",
"https://randomuser.me/api/portraits/women/14.jpg",
"https://randomuser.me/api/portraits/women/15.jpg",
"https://randomuser.me/api/portraits/women/16.jpg",
"https://randomuser.me/api/portraits/women/17.jpg",
"https://randomuser.me/api/portraits/women/18.jpg",
"https://randomuser.me/api/portraits/women/19.jpg",
"https://randomuser.me/api/portraits/women/20.jpg",
"https://randomuser.me/api/portraits/women/21.jpg",
"https://randomuser.me/api/portraits/women/22.jpg",
"https://randomuser.me/api/portraits/women/23.jpg",
"https://randomuser.me/api/portraits/women/24.jpg",
"https://randomuser.me/api/portraits/women/25.jpg",
"https://randomuser.me/api/portraits/women/26.jpg",
"https://randomuser.me/api/portraits/women/27.jpg",
"https://randomuser.me/api/portraits/women/28.jpg",
"https://randomuser.me/api/portraits/women/29.jpg",
"https://randomuser.me/api/portraits/women/30.jpg",
"https://randomuser.me/api/portraits/women/31.jpg",
"https://randomuser.me/api/portraits/women/32.jpg",
"https://randomuser.me/api/portraits/women/33.jpg",
"https://randomuser.me/api/portraits/women/34.jpg",
"https://randomuser.me/api/portraits/women/35.jpg",
"https://randomuser.me/api/portraits/women/36.jpg",
"https://randomuser.me/api/portraits/women/37.jpg",
"https://randomuser.me/api/portraits/women/38.jpg",
"https://randomuser.me/api/portraits/women/39.jpg",
"https://randomuser.me/api/portraits/women/40.jpg",
"https://randomuser.me/api/portraits/women/41.jpg",
"https://randomuser.me/api/portraits/women/42.jpg",
"https://randomuser.me/api/portraits/women/43.jpg",
"https://randomuser.me/api/portraits/women/44.jpg",
"https://randomuser.me/api/portraits/women/45.jpg",
"https://randomuser.me/api/portraits/women/46.jpg",
"https://randomuser.me/api/portraits/women/47.jpg",
"https://randomuser.me/api/portraits/women/48.jpg",
"https://randomuser.me/api/portraits/women/49.jpg",
"https://randomuser.me/api/portraits/women/50.jpg",
"https://randomuser.me/api/portraits/women/51.jpg",
"https://randomuser.me/api/portraits/women/52.jpg",
"https://randomuser.me/api/portraits/women/53.jpg",
"https://randomuser.me/api/portraits/women/54.jpg",
"https://randomuser.me/api/portraits/women/55.jpg",
"https://randomuser.me/api/portraits/women/56.jpg",
"https://randomuser.me/api/portraits/women/57.jpg",
"https://randomuser.me/api/portraits/women/58.jpg",
"https://randomuser.me/api/portraits/women/59.jpg",
"https://randomuser.me/api/portraits/women/60.jpg",
"https://randomuser.me/api/portraits/women/61.jpg",
"https://randomuser.me/api/portraits/women/62.jpg",
"https://randomuser.me/api/portraits/women/63.jpg",
"https://randomuser.me/api/portraits/women/64.jpg",
"https://randomuser.me/api/portraits/women/65.jpg",
"https://randomuser.me/api/portraits/women/66.jpg",
"https://randomuser.me/api/portraits/women/67.jpg",
"https://randomuser.me/api/portraits/women/68.jpg",
"https://randomuser.me/api/portraits/women/69.jpg",
"https://randomuser.me/api/portraits/women/70.jpg",
"https://randomuser.me/api/portraits/women/71.jpg",
"https://randomuser.me/api/portraits/women/72.jpg",
"https://randomuser.me/api/portraits/women/73.jpg",
"https://randomuser.me/api/portraits/women/74.jpg",
"https://randomuser.me/api/portraits/women/75.jpg",
"https://randomuser.me/api/portraits/women/76.jpg",
"https://randomuser.me/api/portraits/women/77.jpg",
"https://randomuser.me/api/portraits/women/78.jpg",
"https://randomuser.me/api/portraits/women/79.jpg",
"https://randomuser.me/api/portraits/women/80.jpg",
"https://randomuser.me/api/portraits/women/81.jpg",
"https://randomuser.me/api/portraits/women/82.jpg",
"https://randomuser.me/api/portraits/women/83.jpg",
"https://randomuser.me/api/portraits/women/84.jpg",
"https://randomuser.me/api/portraits/women/85.jpg",
"https://randomuser.me/api/portraits/women/86.jpg",
"https://randomuser.me/api/portraits/women/87.jpg",
"https://randomuser.me/api/portraits/women/88.jpg",
"https://randomuser.me/api/portraits/women/89.jpg",
"https://randomuser.me/api/portraits/women/90.jpg",
"https://randomuser.me/api/portraits/women/91.jpg",
"https://randomuser.me/api/portraits/women/92.jpg",
"https://randomuser.me/api/portraits/women/93.jpg",
"https://randomuser.me/api/portraits/women/94.jpg",
"https://randomuser.me/api/portraits/women/95.jpg",
"https://randomuser.me/api/portraits/women/96.jpg",
"https://randomuser.me/api/portraits/women/97.jpg",
"https://randomuser.me/api/portraits/women/98.jpg",
"https://randomuser.me/api/portraits/women/99.jpg",
}
var defaultArabicRobotNicknames = []string{
"أمير",
"سيف",
"مالك",
"فارس",
"ليث",
"بدر",
"ماهر",
"رامي",
"كريم",
"سامي",
"عمر",
"يوسف",
"آدم",
"ريان",
"زيد",
"نور",
"ياسين",
"أنس",
"خالد",
"طارق",
"حازم",
"نادر",
"وسيم",
"جواد",
"مروان",
"إياد",
"نبيل",
"هلال",
"تميم",
"سلمان",
"راشد",
"جاسم",
"ماجد",
"سلطان",
"عادل",
"هاني",
"بلال",
"يزن",
"غيث",
"قصي",
"سليم",
"ريان_الليل",
"نجم",
"ملك_اللعبة",
"صقر",
"ذيب",
"أسد",
"الزعيم",
"القناص",
"الأسطورة",
"ظل",
"برق",
"رعد",
"موج",
"نمر",
"شهاب",
"فهد",
"نايف",
"نوف",
"ليان",
"ريم",
"سارة",
"مريم",
"لينا",
"هنا",
"جود",
"تالا",
"دانا",
"لارا",
"ياسمين",
"نوران",
"رؤى",
"سما",
"ملك",
"جنى",
"آية",
"ندى",
"فرح",
"أمل",
"هدى",
"سلمى",
"ليلى",
"رنا",
"نورا",
"ديما",
"مايا",
"ميس",
"رهف",
"شهد",
"غلا",
"لولو",
"وردة",
"قمر",
"نجمة",
"لؤلؤة",
"الملكة",
"أميرة",
"روح",
"حلم",
"بسمة",
}
var defaultEnglishRobotNicknames = []string{
"Alex",
"Jordan",
"Taylor",
"Morgan",
"Riley",
"Nova",
"Phoenix",
"Hunter",
"Luna",
"Mia",
"Noah",
"Liam",
"Ava",
"Emma",
"Oliver",
"Elijah",
"James",
"William",
"Benjamin",
"Lucas",
"Mason",
"Ethan",
"Logan",
"Jackson",
"Levi",
"Sebastian",
"Mateo",
"Jack",
"Owen",
"Theodore",
"Aiden",
"Samuel",
"Joseph",
"John",
"David",
"Wyatt",
"Matthew",
"Luke",
"Asher",
"Carter",
"Julian",
"Grayson",
"Leo",
"Jayden",
"Gabriel",
"Isaac",
"Lincoln",
"Anthony",
"Hudson",
"Dylan",
"Ezra",
"Thomas",
"Charles",
"Christopher",
"Jaxon",
"Maverick",
"Josiah",
"Isaiah",
"Andrew",
"Elias",
"Joshua",
"Nathan",
"Caleb",
"Ryan",
"Adrian",
"Miles",
"Eli",
"Nolan",
"Christian",
"Aaron",
"Cameron",
"Ezekiel",
"Colton",
"Luca",
"Landon",
"Hannah",
"Sofia",
"Isabella",
"Amelia",
"Olivia",
"Charlotte",
"Harper",
"Evelyn",
"Abigail",
"Ella",
"Scarlett",
"Grace",
"Chloe",
"Victoria",
"Aria",
"Penelope",
"Layla",
"Zoey",
"Nora",
"Lily",
"Ellie",
"Violet",
"Aurora",
"Stella",
"Hazel",
}
func normalizeRobotNicknameLanguage(value string) string {
// 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。
func robotAccountLanguage(value string) string {
// likei 昵称和头像是资料事实来源;这里的 language 只保留为账号元数据,兼容后台旧请求传入 ar/en 的使用习惯。
switch strings.ToLower(strings.TrimSpace(value)) {
case "ar", "arabic", "arab":
return robotNicknameLanguageArabic
case "en", "english":
return robotNicknameLanguageEnglish
default:
return robotNicknameLanguageEnglish
}
}
func robotAccountLanguage(nicknameLanguage string) string {
// 用户资料里的 language 跟昵称池保持一致,后续客户端或运营筛选机器人时不用再反推昵称字符集。
if normalizeRobotNicknameLanguage(nicknameLanguage) == robotNicknameLanguageArabic {
return "ar"
case "en", "english":
return "en"
default:
return "en"
}
return "en"
}
func robotNickname(index int, nicknameLanguage string) string {
// 机器人昵称只从单一语言池取值,不能再拼随机 hex否则会出现数字、下划线和英阿混排。
language := normalizeRobotNicknameLanguage(nicknameLanguage)
if language == robotNicknameLanguageArabic {
return robotNicknameFromPool(index, defaultArabicRobotNicknames, "لاعب", language)
}
return robotNicknameFromPool(index, defaultEnglishRobotNicknames, "Player", language)
}
func robotNicknameFromPool(index int, pool []string, fallback string, language string) string {
// 前 100 个直接使用预设名;超过预设数量时用同语言两个预设名拼接,保证批量 200 个仍然不带数字后缀。
if index < 0 {
index = 0
}
if len(pool) == 0 {
return fallback
}
base := sanitizeRobotNickname(pool[index%len(pool)], language)
if base == "" {
base = fallback
}
if index < len(pool) {
return base
}
next := sanitizeRobotNickname(pool[(index%len(pool)+1)%len(pool)], language)
if next == "" || next == base {
next = sanitizeRobotNickname(pool[(index+index/len(pool)+2)%len(pool)], language)
}
if next == "" || next == base {
return base
}
return base + next
}
func sanitizeRobotNickname(value string, language string) string {
// 预设里可能有旧的分隔符或非目标语言字符;写库前统一剔除,保证“英语纯英语、阿拉伯语纯阿拉伯语”。
var builder strings.Builder
for _, item := range strings.TrimSpace(value) {
switch language {
case robotNicknameLanguageArabic:
if unicode.Is(unicode.Arabic, item) {
builder.WriteRune(item)
}
default:
if (item >= 'A' && item <= 'Z') || (item >= 'a' && item <= 'z') {
builder.WriteRune(item)
}
}
}
return builder.String()
}
func robotGender(index int, requested string) string {
@ -501,13 +29,3 @@ func robotGender(index int, requested string) string {
}
return "female"
}
func robotAvatarSource(index int, requested []string) string {
if source := pickString(requested, index); source != "" {
return source
}
if len(defaultRobotAvatarSources) == 0 {
return ""
}
return defaultRobotAvatarSources[index%len(defaultRobotAvatarSources)]
}

View File

@ -0,0 +1,101 @@
package gamemanagement
import (
"context"
"database/sql"
"fmt"
"net/url"
"strings"
"unicode/utf8"
)
type RobotProfile struct {
Nickname string
Avatar string
}
type RobotProfileSource interface {
RandomRobotProfiles(ctx context.Context, count int) ([]RobotProfile, error)
}
type likeiRobotProfileSource struct {
db *sql.DB
}
func NewLikeiRobotProfileSource(db *sql.DB) RobotProfileSource {
if db == nil {
return nil
}
return &likeiRobotProfileSource{db: db}
}
// WithRobotProfileSource 注入机器人资料池;创建机器人时只消费这里返回的线上 likei 用户昵称和头像。
func WithRobotProfileSource(source RobotProfileSource) Option {
return func(h *Handler) {
h.robotProfiles = source
}
}
func (s *likeiRobotProfileSource) RandomRobotProfiles(ctx context.Context, count int) ([]RobotProfile, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("likei robot profile source is not configured")
}
if count <= 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, `
SELECT TRIM(user_nickname), TRIM(user_avatar)
FROM user_base_info
WHERE account_status = 'NORMAL'
AND is_del = 0
AND origin_sys = 'LIKEI'
AND user_avatar IS NOT NULL AND TRIM(user_avatar) <> ''
AND user_nickname IS NOT NULL AND TRIM(user_nickname) <> ''
AND CHAR_LENGTH(TRIM(user_nickname)) <= 64
AND CHAR_LENGTH(TRIM(user_avatar)) <= 512
AND (TRIM(user_avatar) LIKE 'http://%' OR TRIM(user_avatar) LIKE 'https://%')
ORDER BY RAND()
LIMIT ?
`, count)
if err != nil {
return nil, fmt.Errorf("query likei robot profiles: %w", err)
}
defer rows.Close()
profiles := make([]RobotProfile, 0, count)
for rows.Next() {
var profile RobotProfile
if err := rows.Scan(&profile.Nickname, &profile.Avatar); err != nil {
return nil, fmt.Errorf("scan likei robot profile: %w", err)
}
if normalized, ok := normalizeRobotProfile(profile); ok {
profiles = append(profiles, normalized)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate likei robot profiles: %w", err)
}
if len(profiles) < count {
return nil, fmt.Errorf("likei robot profile pool returned %d profiles, need %d", len(profiles), count)
}
return profiles, nil
}
func normalizeRobotProfile(profile RobotProfile) (RobotProfile, bool) {
profile.Nickname = strings.TrimSpace(profile.Nickname)
profile.Avatar = strings.TrimSpace(profile.Avatar)
if profile.Nickname == "" || profile.Avatar == "" {
return RobotProfile{}, false
}
if utf8.RuneCountInString(profile.Nickname) > 64 || utf8.RuneCountInString(profile.Avatar) > 512 {
return RobotProfile{}, false
}
parsed, err := url.Parse(profile.Avatar)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return RobotProfile{}, false
}
if strings.ContainsAny(profile.Avatar, " \t\r\n") {
return RobotProfile{}, false
}
return profile, true
}

View File

@ -0,0 +1,106 @@
package gamemanagement
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
type fakeRobotProfileSource struct {
profiles []RobotProfile
requested int
}
func (s *fakeRobotProfileSource) RandomRobotProfiles(_ context.Context, count int) ([]RobotProfile, error) {
s.requested = count
return s.profiles[:count], nil
}
type fakeRobotUserClient struct {
userclient.Client
created []userclient.QuickCreateAccountRequest
}
func (c *fakeRobotUserClient) QuickCreateAccount(_ context.Context, req userclient.QuickCreateAccountRequest) (*userclient.QuickCreateAccountResult, error) {
c.created = append(c.created, req)
return &userclient.QuickCreateAccountResult{UserID: int64(9000 + len(c.created)), DisplayUserID: "robot"}, nil
}
func (c *fakeRobotUserClient) BatchGetUsers(_ context.Context, _ userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) {
users := make(map[int64]*userclient.User, len(c.created))
for index, created := range c.created {
userID := int64(9001 + index)
users[userID] = &userclient.User{UserID: userID, DisplayUserID: "robot", Username: created.Username, Avatar: created.Avatar}
}
return users, nil
}
type fakeRobotGameClient struct {
gameclient.Client
registered []int64
}
func (c *fakeRobotGameClient) RegisterDiceRobots(_ context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) {
c.registered = append(c.registered, req.GetUserIds()...)
robots := make([]*gamev1.DiceRobot, 0, len(req.GetUserIds()))
for _, userID := range req.GetUserIds() {
robots = append(robots, &gamev1.DiceRobot{AppCode: "lalu", GameId: "dice", UserId: userID, Status: "active"})
}
return &gamev1.RegisterDiceRobotsResponse{Robots: robots}, nil
}
func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) {
gin.SetMode(gin.TestMode)
profiles := &fakeRobotProfileSource{profiles: []RobotProfile{
{Nickname: "likei-one", Avatar: "https://media.haiyihy.com/avatar/one.jpg"},
{Nickname: "likei-two", Avatar: "https://media.haiyihy.com/avatar/two.jpg"},
}}
users := &fakeRobotUserClient{}
games := &fakeRobotGameClient{}
handler := New(games, users, nil, WithRobotProfileSource(profiles))
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(middleware.ContextRequestID, "req-robot-test")
c.Set(middleware.ContextUserID, uint(7))
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/games/self/dice/robots:generate", bytes.NewBufferString(`{
"count": 2,
"nicknameLanguage": "ar",
"avatarUrls": ["https://randomuser.me/api/portraits/men/1.jpg"],
"country": "SA"
}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.GenerateDiceRobots(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if profiles.requested != 2 {
t.Fatalf("profile source requested %d profiles, want 2", profiles.requested)
}
if len(users.created) != 2 {
t.Fatalf("created users = %d, want 2", len(users.created))
}
if users.created[0].Username != "likei-one" || users.created[0].Avatar != "https://media.haiyihy.com/avatar/one.jpg" {
t.Fatalf("first robot profile = %#v", users.created[0])
}
if users.created[1].Username != "likei-two" || users.created[1].Avatar != "https://media.haiyihy.com/avatar/two.jpg" {
t.Fatalf("second robot profile = %#v", users.created[1])
}
if users.created[0].Language != "ar" || users.created[0].Source != "game_robot" || users.created[0].Country != "SA" {
t.Fatalf("robot account metadata = %#v", users.created[0])
}
if len(games.registered) != 2 || games.registered[0] != 9001 || games.registered[1] != 9002 {
t.Fatalf("registered user ids = %#v", games.registered)
}
}

View File

@ -22,7 +22,11 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
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.POST("/admin/game/self-games/:game_id/pool/adjust", middleware.RequirePermission("game:update"), h.AdjustDicePool)
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)

View File

@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/integration/userclient"
@ -51,20 +52,109 @@ func (h *Handler) UpdateDiceConfig(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) AdjustDicePool(c *gin.Context) {
func (h *Handler) GetSelfGameNewUserPolicy(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req dicePoolAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "奖池调整参数不正确")
resp, err := h.game.GetSelfGameNewUserPolicy(c.Request.Context(), &gamev1.GetSelfGameNewUserPolicyRequest{
Meta: requestMeta(c),
GameId: gameID,
})
if err != nil {
response.ServerError(c, "获取新手保护策略失败")
return
}
resp, err := h.game.AdjustDicePool(c.Request.Context(), &gamev1.AdjustDicePoolRequest{
response.OK(c, selfGameNewUserPolicyFromProto(resp.GetPolicy()))
}
func (h *Handler) UpdateSelfGameNewUserPolicy(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req selfGameNewUserPolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "新手保护策略参数不正确")
return
}
resp, err := h.game.UpdateSelfGameNewUserPolicy(c.Request.Context(), &gamev1.UpdateSelfGameNewUserPolicyRequest{
Meta: requestMeta(c),
Policy: req.toProto(gameID),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := selfGameNewUserPolicyFromProto(resp.GetPolicy())
h.auditLog(c, "update-self-game-new-user-policy", "game_self_game_new_user_policies", item.GameID, item.StrategyLevel)
response.OK(c, item)
}
func (h *Handler) ListSelfGameStakePools(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
resp, err := h.game.ListSelfGameStakePools(c.Request.Context(), &gamev1.ListSelfGameStakePoolsRequest{
Meta: requestMeta(c),
GameId: gameID,
})
if err != nil {
response.ServerError(c, "获取档位奖池失败")
return
}
items := make([]selfGameStakePoolDTO, 0, len(resp.GetPools()))
for _, pool := range resp.GetPools() {
items = append(items, selfGameStakePoolFromProto(pool))
}
response.OK(c, gin.H{"items": items, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) UpdateSelfGameStakePool(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
stakeCoin, ok := parseStakeCoinParam(c)
if gameID == "" || !ok {
response.BadRequest(c, "档位奖池参数不正确")
return
}
var req selfGameStakePoolRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "档位奖池配置参数不正确")
return
}
resp, err := h.game.UpdateSelfGameStakePool(c.Request.Context(), &gamev1.UpdateSelfGameStakePoolRequest{
Meta: requestMeta(c),
Pool: req.toProto(gameID, stakeCoin),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := selfGameStakePoolFromProto(resp.GetPool())
h.auditLog(c, "update-self-game-stake-pool", "game_self_game_stake_pools", fmt.Sprintf("%s:%d", gameID, stakeCoin), item.Status)
response.OK(c, item)
}
func (h *Handler) AdjustSelfGameStakePool(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
stakeCoin, ok := parseStakeCoinParam(c)
if gameID == "" || !ok {
response.BadRequest(c, "档位奖池参数不正确")
return
}
var req dicePoolAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "档位奖池调整参数不正确")
return
}
resp, err := h.game.AdjustSelfGameStakePool(c.Request.Context(), &gamev1.AdjustSelfGameStakePoolRequest{
Meta: requestMeta(c),
GameId: gameID,
StakeCoin: stakeCoin,
AmountCoin: req.AmountCoin,
Direction: strings.TrimSpace(req.Direction),
Reason: strings.TrimSpace(req.Reason),
@ -73,10 +163,18 @@ func (h *Handler) AdjustDicePool(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
h.auditLog(c, "adjust-dice-pool", "game_self_game_pools", gameID, fmt.Sprintf("%s:%d", req.Direction, req.AmountCoin))
h.auditLog(c, "adjust-self-game-stake-pool", "game_self_game_stake_pools", fmt.Sprintf("%s:%d", gameID, stakeCoin), fmt.Sprintf("%s:%d", req.Direction, req.AmountCoin))
response.OK(c, gin.H{"adjustment": dicePoolAdjustmentFromProto(resp.GetAdjustment()), "config": diceConfigFromProto(resp.GetConfig())})
}
func parseStakeCoinParam(c *gin.Context) (int64, bool) {
stakeCoin, err := strconv.ParseInt(strings.TrimSpace(c.Param("stake_coin")), 10, 64)
if err != nil || stakeCoin <= 0 {
return 0, false
}
return stakeCoin, true
}
func (h *Handler) ListDiceRobots(c *gin.Context) {
pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50))
requestID := middleware.CurrentRequestID(c)
@ -113,46 +211,48 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
return
}
if req.Count <= 0 {
req.Count = 20
req.Count = defaultRobotGenerateCount
}
if req.Count > 200 {
req.Count = 200
if req.Count > maxRobotGenerateCount {
req.Count = maxRobotGenerateCount
}
nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage)
country := strings.TrimSpace(req.Country)
if country == "" {
country = "SA"
}
if h.robotProfiles == nil {
response.ServerError(c, "likei 机器人资料源未配置")
return
}
// 新机器人资料只从 likei 线上用户库随机抽取;后台不再使用本地昵称池、随机头像站或 COS 转存,
// 这样每次批量创建都面对真实用户资料池,避免少量固定预设反复出现在匹配列表里。
profiles, err := h.robotProfiles.RandomRobotProfiles(c.Request.Context(), int(req.Count))
if err != nil {
response.BadRequest(c, "获取 likei 机器人资料失败: "+err.Error())
return
}
if len(profiles) < int(req.Count) {
response.BadRequest(c, "likei 机器人资料数量不足")
return
}
userIDs := make([]int64, 0, req.Count)
requestID := middleware.CurrentRequestID(c)
actorID := middleware.CurrentUserID(c)
avatarCache := make(map[string]string)
accountLanguage := robotAccountLanguage(req.NicknameLanguage)
for index := int32(0); index < req.Count; index++ {
// 批量机器人必须一次性写齐完整资料;同一请求内相同头像源只转存一次,避免 200 个账号重复下载上传同一张图。
avatarSource := robotAvatarSource(int(index), req.AvatarURLs)
avatarURL := avatarCache[avatarSource]
if avatarURL == "" {
var err error
avatarURL, err = h.uploadRobotAvatarFromSource(c.Request.Context(), actorID, avatarSource)
if err != nil {
response.BadRequest(c, "上传机器人头像失败: "+err.Error())
return
}
avatarCache[avatarSource] = avatarURL
}
profile := profiles[index]
created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{
RequestID: requestID,
Caller: "admin-server",
Password: randomRobotPassword(),
Username: robotNickname(int(index), nicknameLanguage),
Avatar: avatarURL,
Username: profile.Nickname,
Avatar: profile.Avatar,
Gender: robotGender(int(index), req.Gender),
Country: country,
DeviceID: "game-robot-" + randomRobotHex(8),
Source: "game_robot",
InstallChannel: "admin",
Platform: "android",
Language: robotAccountLanguage(nicknameLanguage),
Language: accountLanguage,
Timezone: "UTC",
})
if err != nil {
@ -178,7 +278,7 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) {
response.ServerError(c, "获取机器人用户资料失败")
return
}
h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "dice", fmt.Sprintf("count=%d", len(userIDs)))
h.auditLog(c, "generate-dice-robots", "game_self_game_robots", "universal", fmt.Sprintf("count=%d", len(userIDs)))
response.OK(c, gin.H{"created": len(userIDs), "items": items})
}
@ -248,13 +348,6 @@ func randomRobotHex(size int) string {
return hex.EncodeToString(buf)
}
func pickString(values []string, index int) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[index%len(values)])
}
func parseInt64Param(raw string) (int64, error) {
var value int64
_, err := fmt.Sscan(strings.TrimSpace(raw), &value)

View File

@ -89,6 +89,36 @@ func (h *Handler) ListCoinSellers(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListSalaryWalletHistory(c *gin.Context) {
query, ok := parseSalaryWalletHistoryQuery(c)
if !ok {
return
}
items, total, err := h.service.ListSalaryWalletHistory(c.Request.Context(), query)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) AdjustSalaryWallet(c *gin.Context) {
var req salaryWalletAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "工资钱包操作参数不正确")
return
}
result, err := h.service.AdjustSalaryWallet(c.Request.Context(), adminActorID(c), middleware.CurrentRequestID(c), req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "salary-wallet-adjust", "wallet_entries", req.TargetUserID.Int64(),
fmt.Sprintf("role=%s target_user_id=%d amount_minor=%d transaction_id=%s reason=%q",
result.Role, req.TargetUserID.Int64(), result.AmountMinor, result.TransactionID, strings.TrimSpace(req.Reason)))
response.Created(c, result)
}
func (h *Handler) CreateBDLeader(c *gin.Context) {
var req createBDLeaderRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -246,6 +276,27 @@ func (h *Handler) CreditCoinSellerStock(c *gin.Context) {
response.Created(c, coinSellerStockCreditFromProto(result))
}
func (h *Handler) DebitCoinSellerStock(c *gin.Context) {
sellerUserID, ok := parseInt64ID(c, "user_id")
if !ok {
return
}
var req coinSellerStockDebitRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "币商扣除参数不正确")
return
}
result, err := h.service.DebitCoinSellerStock(c.Request.Context(), adminActorID(c), sellerUserID, middleware.CurrentRequestID(c), req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "coin-seller-stock-debit", "wallet_entries", sellerUserID,
fmt.Sprintf("command_id=%s seller_user_id=%d coin_amount=%d transaction_id=%s reason=%q",
strings.TrimSpace(req.CommandID), sellerUserID, req.CoinAmount, result.GetTransactionId(), strings.TrimSpace(req.Reason)))
response.Created(c, coinSellerStockDebitFromProto(sellerUserID, req.CoinAmount, result))
}
func (h *Handler) GetCoinSellerSalaryRates(c *gin.Context) {
regionID, ok := parseInt64ID(c, "region_id")
if !ok {
@ -394,6 +445,25 @@ func parseListQuery(c *gin.Context) (listQuery, bool) {
return normalizeListQuery(query), true
}
func parseSalaryWalletHistoryQuery(c *gin.Context) (salaryWalletHistoryQuery, bool) {
options := shared.ListOptions(c)
userID, ok := parseOptionalInt64Query(c, "userId", "user_id")
if !ok {
return salaryWalletHistoryQuery{}, false
}
if userID <= 0 {
response.BadRequest(c, "user_id 参数不正确")
return salaryWalletHistoryQuery{}, false
}
query := salaryWalletHistoryQuery{
Page: options.Page,
PageSize: options.PageSize,
Role: c.Query("role"),
UserID: userID,
}
return normalizeSalaryWalletHistoryQuery(query), true
}
func parseOptionalInt64Query(c *gin.Context, primary string, fallback string) (int64, bool) {
raw := strings.TrimSpace(c.Query(primary))
if raw == "" {

View File

@ -10,6 +10,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/modules/shared"
)
type Reader struct {
@ -111,14 +112,23 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
if err != nil {
return nil, 0, err
}
limitSQL := "LIMIT ? OFFSET ?"
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
if query.SortBy == sortBySalaryWallet {
// 工资钱包余额来自 wallet-service 读模型,不能只排序 SQL 当前页;先取出所有匹配身份,补齐余额后再分页。
limitSQL = ""
queryArgs = args
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
@ -137,8 +147,8 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
)
%s
ORDER BY bp.created_at_ms DESC, bp.user_id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
%s
`, whereSQL, limitSQL), queryArgs...)
if err != nil {
return nil, 0, err
}
@ -183,6 +193,13 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
return nil, 0, err
}
}
if err := r.fillBDProfileSalaryWallets(ctx, role, items); err != nil {
return nil, 0, err
}
if query.SortBy == sortBySalaryWallet {
sortBDProfilesBySalaryWallet(items, query.SortDirection)
items = paginateBDProfiles(items, query.Page, query.PageSize)
}
return items, total, nil
}
@ -209,14 +226,23 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
if err != nil {
return nil, 0, err
}
limitSQL := "LIMIT ? OFFSET ?"
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
if query.SortBy == sortBySalaryWallet {
// BD Leader 工资余额同样在 wallet-service排序必须等 Admin 工资钱包资产填充完成后再裁剪当前页。
limitSQL = ""
queryArgs = args
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
@ -234,8 +260,8 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
)
%s
ORDER BY bp.created_at_ms DESC, bp.user_id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
%s
`, whereSQL, limitSQL), queryArgs...)
if err != nil {
return nil, 0, err
}
@ -278,6 +304,13 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
return nil, 0, err
}
if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil {
return nil, 0, err
}
if query.SortBy == sortBySalaryWallet {
sortBDProfilesBySalaryWallet(items, query.SortDirection)
items = paginateBDProfiles(items, query.Page, query.PageSize)
}
return items, total, nil
}
@ -337,6 +370,9 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil {
return nil, err
}
if err := r.fillBDProfileSalaryWallets(ctx, salaryWalletRoleBDLeader, items); err != nil {
return nil, err
}
return item, nil
}
@ -551,14 +587,27 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR owner_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like, like, like)
nowMs := time.Now().UnixMilli()
ownerSQL, ownerArgs := shared.UserIdentityKeywordSQL("owner", "a.owner_user_id", keyword, nowMs)
parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_bd", "a.parent_bd_user_id", keyword, nowMs)
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR " + ownerSQL + " OR " + parentSQL + " OR owner_region.name LIKE ?)"
args = append(args, like, like)
args = append(args, ownerArgs...)
args = append(args, parentArgs...)
args = append(args, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
if err != nil {
return nil, 0, err
}
limitSQL := "LIMIT ? OFFSET ?"
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
if query.SortBy == sortBySalaryWallet {
// Agency 展示的是 owner 的 Agency 工资钱包;先补余额再排序,保证跨页顺序和真实余额一致。
limitSQL = ""
queryArgs = args
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, `+userCountryRegionIDSQL("owner_region")+`, a.parent_bd_user_id,
a.name, a.status, a.join_enabled, a.max_hosts,
@ -569,8 +618,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`
%s
ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
%s
`, whereSQL, limitSQL), queryArgs...)
if err != nil {
return nil, 0, err
}
@ -603,7 +652,17 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
}
items = append(items, item)
}
return items, total, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := r.fillAgencySalaryWallets(ctx, items); err != nil {
return nil, 0, err
}
if query.SortBy == sortBySalaryWallet {
sortAgenciesBySalaryWallet(items, query.SortDirection)
items = paginateAgencies(items, query.Page, query.PageSize)
}
return items, total, nil
}
// ListHostProfiles 只读主播身份事实;成员变更和主播状态变更不在这个列表接口里发生。
@ -634,8 +693,14 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR user_region.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like)
nowMs := time.Now().UnixMilli()
hostSQL, hostArgs := shared.UserIdentityKeywordSQL("u", "hp.user_id", keyword, nowMs)
agencyOwnerSQL, agencyOwnerArgs := shared.UserIdentityKeywordSQL("agency_owner", "a.owner_user_id", keyword, nowMs)
whereSQL += " AND (" + hostSQL + " OR a.name LIKE ? OR " + agencyOwnerSQL + " OR user_region.name LIKE ?)"
args = append(args, hostArgs...)
args = append(args, like)
args = append(args, agencyOwnerArgs...)
args = append(args, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -644,7 +709,8 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
}
limitSQL := "LIMIT ? OFFSET ?"
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
if query.SortBy == "diamond" {
if query.SortBy == sortByDiamond || query.SortBy == sortBySalaryWallet {
// 主播钻石和工资余额都是列表外部聚合值;排序时取全量匹配集,聚合后稳定排序再分页。
limitSQL = ""
queryArgs = args
}
@ -704,10 +770,17 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
for _, item := range items {
item.Diamond = diamonds[item.UserID]
}
if query.SortBy == "diamond" {
if err := r.fillHostSalaryWallets(ctx, items); err != nil {
return nil, 0, err
}
if query.SortBy == sortByDiamond {
sortHostProfilesByDiamond(items, query.SortDirection)
items = paginateHostProfiles(items, query.Page, query.PageSize)
}
if query.SortBy == sortBySalaryWallet {
sortHostProfilesBySalaryWallet(items, query.SortDirection)
items = paginateHostProfiles(items, query.Page, query.PageSize)
}
return items, total, nil
}
@ -736,8 +809,10 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR csp.contact_info LIKE ?)"
args = append(args, like, like, like, like)
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "csp.user_id", keyword, time.Now().UnixMilli())
whereSQL += " AND (" + matchSQL + " OR csp.contact_info LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -1094,6 +1169,50 @@ func coinSellerSortValue(item *CoinSellerListItem, sortBy string) int64 {
}
}
// sortBDProfilesBySalaryWallet 按当前身份的工资钱包可用余额排序;余额相同时保留 SQL 默认创建时间顺序,避免同值行跳动。
func sortBDProfilesBySalaryWallet(items []*userclient.BDProfile, direction string) {
sort.SliceStable(items, func(i, j int) bool {
left := bdProfileSalaryWalletValue(items[i])
right := bdProfileSalaryWalletValue(items[j])
if left == right {
return false
}
if direction == "asc" {
return left < right
}
return left > right
})
}
func bdProfileSalaryWalletValue(item *userclient.BDProfile) int64 {
if item == nil {
return 0
}
return item.SalaryWallet.AvailableAmount
}
// sortAgenciesBySalaryWallet 按 Agency owner 对应的 Agency 工资钱包余额排序,不混用 owner 的其他身份钱包。
func sortAgenciesBySalaryWallet(items []*userclient.Agency, direction string) {
sort.SliceStable(items, func(i, j int) bool {
left := agencySalaryWalletValue(items[i])
right := agencySalaryWalletValue(items[j])
if left == right {
return false
}
if direction == "asc" {
return left < right
}
return left > right
})
}
func agencySalaryWalletValue(item *userclient.Agency) int64 {
if item == nil {
return 0
}
return item.SalaryWallet.AvailableAmount
}
func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string) {
sort.SliceStable(items, func(i, j int) bool {
if items[i].Diamond == items[j].Diamond {
@ -1106,6 +1225,28 @@ func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string
})
}
// sortHostProfilesBySalaryWallet 按主播工资钱包可用余额排序,和钻石排序一样在分页前处理跨页顺序。
func sortHostProfilesBySalaryWallet(items []*userclient.HostProfile, direction string) {
sort.SliceStable(items, func(i, j int) bool {
left := hostProfileSalaryWalletValue(items[i])
right := hostProfileSalaryWalletValue(items[j])
if left == right {
return false
}
if direction == "asc" {
return left < right
}
return left > right
})
}
func hostProfileSalaryWalletValue(item *userclient.HostProfile) int64 {
if item == nil {
return 0
}
return item.SalaryWallet.AvailableAmount
}
func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize int) []*CoinSellerListItem {
if page < 1 {
page = 1
@ -1124,6 +1265,42 @@ func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize
return items[start:end]
}
func paginateBDProfiles(items []*userclient.BDProfile, page int, pageSize int) []*userclient.BDProfile {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
start := (page - 1) * pageSize
if start >= len(items) {
return []*userclient.BDProfile{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func paginateAgencies(items []*userclient.Agency, page int, pageSize int) []*userclient.Agency {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
start := (page - 1) * pageSize
if start >= len(items) {
return []*userclient.Agency{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func paginateHostProfiles(items []*userclient.HostProfile, page int, pageSize int) []*userclient.HostProfile {
if page < 1 {
page = 1

View File

@ -1,5 +1,45 @@
package hostorg
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// flexibleInt64 只用于 admin HTTP 请求入参:前端新合约会按字符串提交用户长 ID旧页面或脚本可能仍提交 JSON number。
// 这里同时接受两种形态,进入业务层前仍落成 int64保证后续短 ID 解析、RPC 参数和审计日志不出现两套类型分支。
type flexibleInt64 int64
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
raw = strings.TrimSpace(text)
if raw == "" {
*v = 0
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 id %q", raw)
}
*v = flexibleInt64(parsed)
return nil
}
func (v flexibleInt64) Int64() int64 {
return int64(v)
}
type listQuery struct {
Page int
PageSize int
@ -14,17 +54,17 @@ type listQuery struct {
}
type createBDLeaderRequest struct {
CommandID string `json:"commandId" binding:"required"`
TargetUserID int64 `json:"targetUserId" binding:"required"`
PositionAlias string `json:"positionAlias"`
Reason string `json:"reason"`
CommandID string `json:"commandId" binding:"required"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
PositionAlias string `json:"positionAlias"`
Reason string `json:"reason"`
}
type createBDRequest struct {
CommandID string `json:"commandId" binding:"required"`
TargetUserID int64 `json:"targetUserId" binding:"required"`
ParentLeaderUserID int64 `json:"parentLeaderUserId"`
Reason string `json:"reason"`
CommandID string `json:"commandId" binding:"required"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
ParentLeaderUserID flexibleInt64 `json:"parentLeaderUserId"`
Reason string `json:"reason"`
}
type bdStatusRequest struct {
@ -40,10 +80,10 @@ type bdLeaderPositionAliasRequest struct {
}
type createCoinSellerRequest struct {
CommandID string `json:"commandId" binding:"required"`
Contact string `json:"contact"`
TargetUserID int64 `json:"targetUserId" binding:"required"`
Reason string `json:"reason"`
CommandID string `json:"commandId" binding:"required"`
Contact string `json:"contact"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
Reason string `json:"reason"`
}
type coinSellerStatusRequest struct {
@ -63,6 +103,13 @@ type coinSellerStockCreditRequest struct {
Reason string `json:"reason"`
}
type coinSellerStockDebitRequest struct {
CommandID string `json:"commandId" binding:"required"`
CoinAmount int64 `json:"coinAmount" binding:"required"`
EvidenceRef string `json:"evidenceRef"`
Reason string `json:"reason" binding:"required"`
}
type replaceCoinSellerSalaryRatesRequest struct {
Tiers []coinSellerSalaryRateTierRequest `json:"tiers" binding:"required"`
}
@ -79,12 +126,12 @@ type coinSellerSalaryRateTierRequest struct {
}
type createAgencyRequest struct {
CommandID string `json:"commandId" binding:"required"`
OwnerUserID int64 `json:"ownerUserId" binding:"required"`
ParentBDUserID int64 `json:"parentBdUserId"`
Name string `json:"name" binding:"required"`
JoinEnabled *bool `json:"joinEnabled" binding:"required"`
Reason string `json:"reason"`
CommandID string `json:"commandId" binding:"required"`
OwnerUserID flexibleInt64 `json:"ownerUserId" binding:"required"`
ParentBDUserID flexibleInt64 `json:"parentBdUserId"`
Name string `json:"name" binding:"required"`
JoinEnabled *bool `json:"joinEnabled" binding:"required"`
Reason string `json:"reason"`
}
type agencyJoinEnabledRequest struct {

View File

@ -0,0 +1,31 @@
package hostorg
import (
"encoding/json"
"testing"
)
func TestFlexibleInt64AcceptsStringAndNumber(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
want int64
}{
{name: "string", body: `{"targetUserId":"323096761415507968"}`, want: 323096761415507968},
{name: "number", body: `{"targetUserId":323096761415507968}`, want: 323096761415507968},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var req createBDLeaderRequest
if err := json.Unmarshal([]byte(tc.body), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if got := req.TargetUserID.Int64(); got != tc.want {
t.Fatalf("target user id = %d, want %d", got, tc.want)
}
})
}
}

View File

@ -14,6 +14,14 @@ type coinSellerStockCreditResponse struct {
CreatedAtMS int64 `json:"createdAtMs"`
}
type coinSellerStockDebitResponse struct {
TransactionID string `json:"transactionId"`
SellerUserID int64 `json:"sellerUserId,string"`
CoinAmount int64 `json:"coinAmount"`
AvailableDelta int64 `json:"availableDelta"`
BalanceAfter int64 `json:"balanceAfter"`
}
func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockResponse) coinSellerStockCreditResponse {
if item == nil {
return coinSellerStockCreditResponse{}
@ -30,3 +38,20 @@ func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockRes
CreatedAtMS: item.GetCreatedAtMs(),
}
}
func coinSellerStockDebitFromProto(sellerUserID int64, coinAmount int64, item *walletv1.AdminCreditAssetResponse) coinSellerStockDebitResponse {
if item == nil {
return coinSellerStockDebitResponse{SellerUserID: sellerUserID, CoinAmount: coinAmount, AvailableDelta: -coinAmount}
}
balanceAfter := int64(0)
if item.GetBalance() != nil {
balanceAfter = item.GetBalance().GetAvailableAmount()
}
return coinSellerStockDebitResponse{
TransactionID: item.GetTransactionId(),
SellerUserID: sellerUserID,
CoinAmount: coinAmount,
AvailableDelta: -coinAmount,
BalanceAfter: balanceAfter,
}
}

View File

@ -21,10 +21,13 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/agencies/:agency_id/delete", middleware.RequirePermission("agency:delete"), h.DeleteAgency)
protected.POST("/admin/agencies/:agency_id/join-enabled", middleware.RequirePermission("agency:status"), h.SetAgencyJoinEnabled)
protected.GET("/admin/hosts", middleware.RequirePermission("host:view"), h.ListHosts)
protected.GET("/admin/host-org/salary-wallets/history", middleware.RequireAnyPermission("host:view", "agency:view", "bd:view"), h.ListSalaryWalletHistory)
protected.POST("/admin/host-org/salary-wallets/adjust", middleware.RequirePermission("host-salary-settlement:settle"), h.AdjustSalaryWallet)
protected.GET("/admin/coin-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellers)
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller)
protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus)
protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock)
protected.POST("/admin/coin-sellers/:user_id/stock-debits", middleware.RequirePermission("coin-seller:stock-credit"), h.DebitCoinSellerStock)
protected.GET("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.GetCoinSellerSalaryRates)
protected.PUT("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.ReplaceCoinSellerSalaryRates)
}

View File

@ -0,0 +1,557 @@
package hostorg
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
salaryWalletRoleHost = "host"
salaryWalletRoleAgency = "agency"
salaryWalletRoleBD = "bd"
salaryWalletRoleBDLeader = "bd_leader"
assetHostSalaryUSD = "HOST_SALARY_USD"
assetAgencySalaryUSD = "AGENCY_SALARY_USD"
assetBDSalaryUSD = "BD_SALARY_USD"
assetAdminSalaryUSD = "ADMIN_SALARY_USD"
salaryWalletAdjustIncrease = "increase"
salaryWalletAdjustDecrease = "decrease"
)
type salaryWalletHistoryQuery struct {
Page int
PageSize int
Role string
UserID int64
}
type salaryWalletAdjustRequest struct {
CommandID string `json:"commandId"`
Role string `json:"role" binding:"required"`
TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"`
Type string `json:"type" binding:"required"`
AmountMinor int64 `json:"amountMinor" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
type salaryWalletOperatorDTO struct {
AdminID string `json:"adminId"`
Username string `json:"username"`
Name string `json:"name"`
}
type salaryWalletHistoryItem struct {
EntryID int64 `json:"entryId"`
TransactionID string `json:"transactionId"`
CommandID string `json:"commandId"`
BizType string `json:"bizType"`
ExternalRef string `json:"externalRef"`
Role string `json:"role"`
UserID string `json:"userId"`
AssetType string `json:"assetType"`
Direction string `json:"direction"`
AmountMinor int64 `json:"amountMinor"`
AvailableDelta int64 `json:"availableDelta"`
AvailableAfter int64 `json:"availableAfter"`
FrozenDelta int64 `json:"frozenDelta"`
FrozenAfter int64 `json:"frozenAfter"`
CounterpartyUserID string `json:"counterpartyUserId"`
RoomID string `json:"roomId"`
Reason string `json:"reason"`
OperatorUserID string `json:"operatorUserId"`
Operator salaryWalletOperatorDTO `json:"operator"`
Metadata map[string]any `json:"metadata"`
CreatedAtMS int64 `json:"createdAtMs"`
}
type salaryWalletAdjustResult struct {
TransactionID string `json:"transactionId"`
Role string `json:"role"`
UserID string `json:"userId"`
AssetType string `json:"assetType"`
AmountMinor int64 `json:"amountMinor"`
Reason string `json:"reason"`
Balance userclient.SalaryWallet `json:"balance"`
}
func (r *Reader) fillBDProfileSalaryWallets(ctx context.Context, role string, items []*userclient.BDProfile) error {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
}
wallets, err := r.salaryWalletBalances(ctx, appctx.FromContext(ctx), role, userIDs)
if err != nil {
return err
}
for _, item := range items {
item.SalaryWallet = wallets[item.UserID]
}
return nil
}
func (r *Reader) fillAgencySalaryWallets(ctx context.Context, items []*userclient.Agency) error {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.OwnerUserID > 0 {
userIDs = append(userIDs, item.OwnerUserID)
}
}
wallets, err := r.salaryWalletBalances(ctx, appctx.FromContext(ctx), salaryWalletRoleAgency, userIDs)
if err != nil {
return err
}
for _, item := range items {
item.SalaryWallet = wallets[item.OwnerUserID]
}
return nil
}
func (r *Reader) fillHostSalaryWallets(ctx context.Context, items []*userclient.HostProfile) error {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
}
wallets, err := r.salaryWalletBalances(ctx, appctx.FromContext(ctx), salaryWalletRoleHost, userIDs)
if err != nil {
return err
}
for _, item := range items {
item.SalaryWallet = wallets[item.UserID]
}
return nil
}
func (r *Reader) salaryWalletBalances(ctx context.Context, appCode string, role string, userIDs []int64) (map[int64]userclient.SalaryWallet, error) {
role = normalizeSalaryWalletRole(role)
assetType := salaryWalletAssetType(role)
result := make(map[int64]userclient.SalaryWallet, len(userIDs))
for _, userID := range positiveUniqueIDs(userIDs) {
// 列表必须稳定返回该身份对应的钱包对象;没有 wallet_accounts 行时余额就是 0而不是让前端猜资产类型。
result[userID] = userclient.SalaryWallet{Role: role, UserID: userID, AssetType: assetType}
}
if r == nil || r.walletDB == nil || len(result) == 0 {
return result, nil
}
args := []any{appCode, assetType}
for userID := range result {
args = append(args, userID)
}
rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
SELECT user_id, available_amount, frozen_amount, updated_at_ms
FROM wallet_accounts
WHERE app_code = ? AND asset_type = ? AND user_id IN (%s)
`, sqlPlaceholders(len(result))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID int64
wallet := userclient.SalaryWallet{Role: role, AssetType: assetType}
if err := rows.Scan(&userID, &wallet.AvailableAmount, &wallet.FrozenAmount, &wallet.UpdatedAtMs); err != nil {
return nil, err
}
wallet.UserID = userID
result[userID] = wallet
}
return result, rows.Err()
}
func (s *Service) ListSalaryWalletHistory(ctx context.Context, query salaryWalletHistoryQuery) ([]salaryWalletHistoryItem, int64, error) {
query = normalizeSalaryWalletHistoryQuery(query)
if s == nil || s.reader == nil || s.reader.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
}
if query.Role == "" || query.UserID <= 0 {
return nil, 0, fmt.Errorf("role and user_id are required")
}
// 历史查询先验证用户确实拥有对应组织身份,避免拿 host 身份去看 agency/BD 的工资资产流水。
if err := s.reader.requireSalaryWalletIdentity(ctx, query.Role, query.UserID); err != nil {
return nil, 0, err
}
return s.reader.listSalaryWalletHistory(ctx, query)
}
func (r *Reader) listSalaryWalletHistory(ctx context.Context, query salaryWalletHistoryQuery) ([]salaryWalletHistoryItem, int64, error) {
appCode := appctx.FromContext(ctx)
assetType := salaryWalletAssetType(query.Role)
args := []any{appCode, query.UserID, assetType}
whereSQL := `WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type = ?`
var total int64
if err := r.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL, args...,
).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.walletDB.QueryContext(ctx, `
SELECT e.entry_id, e.transaction_id, wt.command_id, wt.biz_type, wt.external_ref,
e.available_delta, e.available_after, e.frozen_delta, e.frozen_after,
e.counterparty_user_id, e.room_id, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL+`
ORDER BY e.created_at_ms DESC, e.entry_id DESC
LIMIT ? OFFSET ?`, append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]salaryWalletHistoryItem, 0, query.PageSize)
operatorIDs := make([]int64, 0, query.PageSize)
for rows.Next() {
var item salaryWalletHistoryItem
var counterpartyUserID int64
var metadataJSON string
if err := rows.Scan(
&item.EntryID,
&item.TransactionID,
&item.CommandID,
&item.BizType,
&item.ExternalRef,
&item.AvailableDelta,
&item.AvailableAfter,
&item.FrozenDelta,
&item.FrozenAfter,
&counterpartyUserID,
&item.RoomID,
&metadataJSON,
&item.CreatedAtMS,
); err != nil {
return nil, 0, err
}
metadata, err := parseSalaryWalletMetadata(metadataJSON)
if err != nil {
return nil, 0, err
}
operatorID := salaryWalletMetadataInt64(metadata, "operator_user_id", "operatorUserId")
if operatorID > 0 {
operatorIDs = append(operatorIDs, operatorID)
}
item.Role = query.Role
item.UserID = strconv.FormatInt(query.UserID, 10)
item.AssetType = assetType
item.Direction = salaryWalletDirection(item.AvailableDelta)
item.AmountMinor = item.AvailableDelta
item.CounterpartyUserID = formatSalaryWalletID(counterpartyUserID)
item.Reason = salaryWalletMetadataString(metadata, "reason")
item.OperatorUserID = formatSalaryWalletID(operatorID)
item.Metadata = metadata
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
operators, err := r.salaryWalletAdminProfiles(ctx, operatorIDs)
if err != nil {
return nil, 0, err
}
for i := range items {
operatorID, _ := strconv.ParseInt(items[i].OperatorUserID, 10, 64)
if operator, ok := operators[operatorID]; ok {
items[i].Operator = operator
}
}
return items, total, nil
}
func (s *Service) AdjustSalaryWallet(ctx context.Context, actorID int64, requestID string, req salaryWalletAdjustRequest) (salaryWalletAdjustResult, error) {
if s == nil || s.walletClient == nil || s.reader == nil {
return salaryWalletAdjustResult{}, fmt.Errorf("wallet client is not configured")
}
role := normalizeSalaryWalletRole(req.Role)
targetUserID := req.TargetUserID.Int64()
if role == "" || targetUserID <= 0 || req.AmountMinor <= 0 {
return salaryWalletAdjustResult{}, fmt.Errorf("role, target_user_id and amount are required")
}
if actorID <= 0 {
return salaryWalletAdjustResult{}, fmt.Errorf("operator_user_id is required")
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return salaryWalletAdjustResult{}, fmt.Errorf("reason is required")
}
if len(reason) > 512 {
return salaryWalletAdjustResult{}, fmt.Errorf("reason is too long")
}
// 调整工资钱包前必须确认目标用户具备该身份;同一个 user_id 可能同时有多种身份,资产类型不能由前端随意指定。
if err := s.reader.requireSalaryWalletIdentity(ctx, role, targetUserID); err != nil {
return salaryWalletAdjustResult{}, err
}
amount := req.AmountMinor
adjustType := strings.ToLower(strings.TrimSpace(req.Type))
switch adjustType {
case salaryWalletAdjustIncrease:
case salaryWalletAdjustDecrease:
// wallet-service 的 AdminCreditAsset 以带符号金额入账,扣减也走同一条幂等账务链路并由钱包层做余额不透支校验。
amount = -amount
default:
return salaryWalletAdjustResult{}, fmt.Errorf("salary adjustment type is invalid")
}
commandID := strings.TrimSpace(req.CommandID)
if commandID == "" {
commandID = defaultSalaryWalletCommandID(requestID)
}
if len(commandID) > 128 {
return salaryWalletAdjustResult{}, fmt.Errorf("command_id is too long")
}
assetType := salaryWalletAssetType(role)
// 手工增减工资只提交目标身份对应的工资资产;交易、分录、余额和幂等命令统一由 wallet-service 落库。
resp, err := s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
CommandId: commandID,
TargetUserId: targetUserID,
AssetType: assetType,
Amount: amount,
OperatorUserId: actorID,
Reason: reason,
EvidenceRef: defaultSalaryWalletEvidenceRef(requestID),
AppCode: appctx.FromContext(ctx),
})
if err != nil {
return salaryWalletAdjustResult{}, err
}
wallet := userclient.SalaryWallet{
Role: role,
UserID: targetUserID,
AssetType: assetType,
AvailableAmount: 0,
FrozenAmount: 0,
UpdatedAtMs: time.Now().UnixMilli(),
}
if balance := resp.GetBalance(); balance != nil {
wallet.AvailableAmount = balance.GetAvailableAmount()
wallet.FrozenAmount = balance.GetFrozenAmount()
}
return salaryWalletAdjustResult{
TransactionID: resp.GetTransactionId(),
Role: role,
UserID: strconv.FormatInt(targetUserID, 10),
AssetType: assetType,
AmountMinor: amount,
Reason: reason,
Balance: wallet,
}, nil
}
func (r *Reader) requireSalaryWalletIdentity(ctx context.Context, role string, userID int64) error {
if r == nil || r.db == nil {
return errUserDBNotConfigured()
}
if userID <= 0 {
return fmt.Errorf("user_id is required")
}
appCode := appctx.FromContext(ctx)
var query string
args := []any{appCode, userID}
switch normalizeSalaryWalletRole(role) {
case salaryWalletRoleHost:
query = `SELECT COUNT(1) FROM host_profiles WHERE app_code = ? AND user_id = ?`
case salaryWalletRoleAgency:
query = `SELECT COUNT(1) FROM agencies WHERE app_code = ? AND owner_user_id = ? AND status <> 'deleted'`
case salaryWalletRoleBD:
query = `SELECT COUNT(1) FROM bd_profiles WHERE app_code = ? AND user_id = ? AND role = 'bd'`
case salaryWalletRoleBDLeader:
query = `SELECT COUNT(1) FROM bd_leader_profiles WHERE app_code = ? AND user_id = ?`
default:
return fmt.Errorf("salary wallet role is invalid")
}
var count int
if err := r.db.QueryRowContext(ctx, query, args...).Scan(&count); err != nil {
return err
}
if count == 0 {
return fmt.Errorf("salary wallet identity not found")
}
return nil
}
func (r *Reader) salaryWalletAdminProfiles(ctx context.Context, adminIDs []int64) (map[int64]salaryWalletOperatorDTO, error) {
result := make(map[int64]salaryWalletOperatorDTO, len(adminIDs))
if r == nil || r.adminDB == nil {
return result, nil
}
adminIDs = positiveUniqueIDs(adminIDs)
if len(adminIDs) == 0 {
return result, nil
}
args := make([]any, 0, len(adminIDs))
for _, id := range adminIDs {
args = append(args, id)
}
rows, err := r.adminDB.QueryContext(ctx, fmt.Sprintf(`
SELECT id, COALESCE(username, ''), COALESCE(name, '')
FROM admin_users
WHERE id IN (%s)
`, sqlPlaceholders(len(adminIDs))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
var item salaryWalletOperatorDTO
if err := rows.Scan(&id, &item.Username, &item.Name); err != nil {
return nil, err
}
item.AdminID = strconv.FormatInt(id, 10)
result[id] = item
}
return result, rows.Err()
}
func normalizeSalaryWalletHistoryQuery(query salaryWalletHistoryQuery) salaryWalletHistoryQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Role = normalizeSalaryWalletRole(query.Role)
return query
}
func normalizeSalaryWalletRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case salaryWalletRoleHost:
return salaryWalletRoleHost
case salaryWalletRoleAgency:
return salaryWalletRoleAgency
case salaryWalletRoleBD:
return salaryWalletRoleBD
case salaryWalletRoleBDLeader, "leader", "bdleader", "admin":
return salaryWalletRoleBDLeader
default:
return ""
}
}
func salaryWalletAssetType(role string) string {
switch normalizeSalaryWalletRole(role) {
case salaryWalletRoleHost:
return assetHostSalaryUSD
case salaryWalletRoleAgency:
return assetAgencySalaryUSD
case salaryWalletRoleBD:
return assetBDSalaryUSD
case salaryWalletRoleBDLeader:
// BD Leader 在工资政策里沿用 admin 结算身份,所以列表和历史都读取 ADMIN_SALARY_USD。
return assetAdminSalaryUSD
default:
return ""
}
}
func salaryWalletDirection(delta int64) string {
if delta < 0 {
return "expense"
}
return "income"
}
func parseSalaryWalletMetadata(value string) (map[string]any, error) {
value = strings.TrimSpace(value)
if value == "" || value == "null" {
return map[string]any{}, nil
}
var metadata map[string]any
if err := json.Unmarshal([]byte(value), &metadata); err != nil {
return nil, err
}
if metadata == nil {
return map[string]any{}, nil
}
return metadata, nil
}
func salaryWalletMetadataString(metadata map[string]any, key string) string {
value, ok := metadata[key]
if !ok || value == nil {
return ""
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text)
}
return strings.TrimSpace(fmt.Sprint(value))
}
func salaryWalletMetadataInt64(metadata map[string]any, keys ...string) int64 {
for _, key := range keys {
value, ok := metadata[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed
}
}
}
return 0
}
func formatSalaryWalletID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func positiveUniqueIDs(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 defaultSalaryWalletCommandID(requestID string) string {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return "salary-wallet-adjustment-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
}
return "salary-wallet-adjustment-" + requestID
}
func defaultSalaryWalletEvidenceRef(requestID string) string {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return "salary-wallet-adjustment"
}
return "admin-request:" + requestID
}

View File

@ -20,6 +20,7 @@ const (
paidCurrencyUSDT = "USDT"
sortByDiamond = "diamond"
sortByMerchantBalance = "merchant_balance"
sortBySalaryWallet = "salary_wallet"
sortByTotalRechargeUSDT = "total_recharge_usdt"
bdLeaderPositionAliasMaxRunes = 64
)
@ -118,7 +119,7 @@ func (s *Service) ReplaceCoinSellerSalaryRates(ctx context.Context, actorID int6
}
func (s *Service) CreateBDLeader(ctx context.Context, actorID int64, requestID string, req createBDLeaderRequest) (*userclient.BDProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil {
return nil, err
}
@ -167,17 +168,17 @@ func (s *Service) UpdateBDLeaderPositionAlias(ctx context.Context, actorID int64
}
func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, req createBDRequest) (*userclient.BDProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil {
return nil, err
}
parentLeaderUserID := int64(0)
if req.ParentLeaderUserID < 0 {
if req.ParentLeaderUserID.Int64() < 0 {
return nil, fmt.Errorf("parent_leader_user_id must not be negative")
}
if req.ParentLeaderUserID > 0 {
if req.ParentLeaderUserID.Int64() > 0 {
// 父级 Leader 是可选关系;传入短 ID 时才解析成内部 user_id未传时保留 0 表示独立 BD。
parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID)
parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID.Int64())
if err != nil {
return nil, err
}
@ -214,7 +215,7 @@ func (s *Service) DeleteBDLeader(ctx context.Context, targetUserID int64) (*user
}
func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID string, req createCoinSellerRequest) (*userclient.CoinSellerProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil {
return nil, err
}
@ -293,6 +294,68 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell
return nil, fmt.Errorf("stock type is invalid")
}
seller, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID)
if err != nil {
return nil, err
}
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: sellerUserID,
SellerCountryId: seller.CountryID,
SellerRegionId: seller.RegionID,
StockType: stockType,
CoinAmount: req.CoinAmount,
PaidCurrencyCode: paidCurrencyCode,
PaidAmountMicro: paidAmountMicro,
PaymentRef: paymentRef,
EvidenceRef: evidenceRef,
OperatorUserId: actorID,
Reason: reason,
AppCode: appctx.FromContext(ctx),
})
}
func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockDebitRequest) (*walletv1.AdminCreditAssetResponse, error) {
if s.walletClient == nil {
return nil, fmt.Errorf("wallet client is not configured")
}
if sellerUserID <= 0 || actorID <= 0 {
return nil, fmt.Errorf("seller_user_id and operator_user_id are required")
}
commandID := strings.TrimSpace(req.CommandID)
reason := strings.TrimSpace(req.Reason)
evidenceRef := strings.TrimSpace(req.EvidenceRef)
if commandID == "" || req.CoinAmount <= 0 {
return nil, fmt.Errorf("command_id and coin_amount are required")
}
if reason == "" {
return nil, fmt.Errorf("reason is required")
}
if len(reason) > 512 || len(commandID) > 128 || len(evidenceRef) > 256 {
return nil, fmt.Errorf("coin seller debit text fields are too long")
}
if _, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID); err != nil {
return nil, err
}
// 币商扣除是运营对币商专用库存的负向调账:只扣 COIN_SELLER_COIN不写普通 COIN也不写进货记录。
// wallet-service 的 AdminCreditAsset 负责余额不足校验、幂等冲突检测、wallet_entries 和 outbox 原子落账。
return s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
CommandId: commandID,
TargetUserId: sellerUserID,
AssetType: coinSellerMerchantAssetType,
Amount: -req.CoinAmount,
OperatorUserId: actorID,
Reason: reason,
EvidenceRef: evidenceRef,
AppCode: appctx.FromContext(ctx),
})
}
func (s *Service) activeCoinSellerUser(ctx context.Context, sellerUserID int64, requestID string) (*userclient.User, error) {
if s == nil || s.userClient == nil {
return nil, fmt.Errorf("user client is not configured")
}
profile, err := s.userClient.GetCoinSellerProfile(ctx, userclient.GetCoinSellerProfileRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
@ -315,36 +378,21 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell
if seller == nil || seller.CountryID <= 0 || seller.RegionID <= 0 {
return nil, fmt.Errorf("coin seller country and region are required")
}
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: sellerUserID,
SellerCountryId: seller.CountryID,
SellerRegionId: seller.RegionID,
StockType: stockType,
CoinAmount: req.CoinAmount,
PaidCurrencyCode: paidCurrencyCode,
PaidAmountMicro: paidAmountMicro,
PaymentRef: paymentRef,
EvidenceRef: evidenceRef,
OperatorUserId: actorID,
Reason: reason,
AppCode: appctx.FromContext(ctx),
})
return seller, nil
}
func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) {
ownerUserID, err := s.resolveDisplayUserID(ctx, requestID, req.OwnerUserID)
ownerUserID, err := s.resolveDisplayUserID(ctx, requestID, req.OwnerUserID.Int64())
if err != nil {
return nil, err
}
parentBDUserID := int64(0)
if req.ParentBDUserID < 0 {
if req.ParentBDUserID.Int64() < 0 {
return nil, fmt.Errorf("parent_bd_user_id must not be negative")
}
if req.ParentBDUserID > 0 {
if req.ParentBDUserID.Int64() > 0 {
// 父级 BD 是可选关系;传入短 ID 时才解析成内部 user_id未传时保留 0 表示独立 Agency。
parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID)
parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID.Int64())
if err != nil {
return nil, err
}
@ -542,7 +590,7 @@ func normalizeListQuery(query listQuery) listQuery {
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
switch query.SortBy {
case sortByDiamond, sortByMerchantBalance, sortByTotalRechargeUSDT:
case sortByDiamond, sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT:
default:
query.SortBy = ""
query.SortDirection = ""

View File

@ -3,11 +3,13 @@ package hostorg
import (
"strings"
"testing"
"hyapp-admin-server/internal/integration/userclient"
)
// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受
func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) {
for _, sortBy := range []string{sortByMerchantBalance, sortByTotalRechargeUSDT} {
// TestNormalizeListQueryAllowsComputedSorts 锁定后台列表支持的计算排序字段不会在查询归一化时被清掉
func TestNormalizeListQueryAllowsComputedSorts(t *testing.T) {
for _, sortBy := range []string{sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT} {
query := normalizeListQuery(listQuery{Page: 1, PageSize: 50, SortBy: sortBy, SortDirection: "asc"})
if query.SortBy != sortBy || query.SortDirection != "asc" {
t.Fatalf("expected sort %s asc to be kept, got sort_by=%q direction=%q", sortBy, query.SortBy, query.SortDirection)
@ -54,6 +56,30 @@ func TestUserCountryRegionSQLUsesCountryMapping(t *testing.T) {
}
}
// TestSalaryWalletAssetTypeByRole 锁定四个组织身份读取各自工资钱包资产BD Leader 复用 Admin 工资资产,不能误读普通 BD 资产。
func TestSalaryWalletAssetTypeByRole(t *testing.T) {
cases := map[string]string{
"host": assetHostSalaryUSD,
"agency": assetAgencySalaryUSD,
"bd": assetBDSalaryUSD,
"bd_leader": assetAdminSalaryUSD,
"admin": assetAdminSalaryUSD,
}
for role, want := range cases {
if got := salaryWalletAssetType(role); got != want {
t.Fatalf("role %s salary wallet asset mismatch: got %q want %q", role, got, want)
}
}
}
// TestNormalizeSalaryWalletHistoryQueryCapsPageSize 避免历史抽屉一次拉太多钱包分录,同时兼容 admin 作为 BD Leader 的旧口径。
func TestNormalizeSalaryWalletHistoryQueryCapsPageSize(t *testing.T) {
query := normalizeSalaryWalletHistoryQuery(salaryWalletHistoryQuery{Page: 0, PageSize: 500, Role: "admin", UserID: 7})
if query.Page != 1 || query.PageSize != 100 || query.Role != salaryWalletRoleBDLeader || query.UserID != 7 {
t.Fatalf("salary wallet history query normalized incorrectly: %+v", query)
}
}
// TestSortCoinSellerListItemsComputedFields 验证 wallet 聚合字段排序只改变展示顺序,不改动币商身份数据。
func TestSortCoinSellerListItemsComputedFields(t *testing.T) {
items := []*CoinSellerListItem{
@ -72,3 +98,45 @@ func TestSortCoinSellerListItemsComputedFields(t *testing.T) {
t.Fatalf("total recharge usdt asc order mismatch: %v", got)
}
}
// TestSortSalaryWalletComputedFields 覆盖四个组织列表的工资钱包余额排序;这些余额来自 wallet-service必须在分页前排序。
func TestSortSalaryWalletComputedFields(t *testing.T) {
bds := []*userclient.BDProfile{
{UserID: 1, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 100}},
{UserID: 2, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 300}},
{UserID: 3, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 200}},
}
sortBDProfilesBySalaryWallet(bds, "desc")
if got := []int64{bds[0].UserID, bds[1].UserID, bds[2].UserID}; got[0] != 2 || got[1] != 3 || got[2] != 1 {
t.Fatalf("bd salary wallet desc order mismatch: %v", got)
}
if page := paginateBDProfiles(bds, 2, 2); len(page) != 1 || page[0].UserID != 1 {
t.Fatalf("bd salary wallet pagination mismatch: %+v", page)
}
agencies := []*userclient.Agency{
{AgencyID: 10, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 100}},
{AgencyID: 20, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 300}},
{AgencyID: 30, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 200}},
}
sortAgenciesBySalaryWallet(agencies, "asc")
if got := []int64{agencies[0].AgencyID, agencies[1].AgencyID, agencies[2].AgencyID}; got[0] != 10 || got[1] != 30 || got[2] != 20 {
t.Fatalf("agency salary wallet asc order mismatch: %v", got)
}
if page := paginateAgencies(agencies, 2, 2); len(page) != 1 || page[0].AgencyID != 20 {
t.Fatalf("agency salary wallet pagination mismatch: %+v", page)
}
hosts := []*userclient.HostProfile{
{UserID: 1, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 100}},
{UserID: 2, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 300}},
{UserID: 3, SalaryWallet: userclient.SalaryWallet{AvailableAmount: 200}},
}
sortHostProfilesBySalaryWallet(hosts, "desc")
if got := []int64{hosts[0].UserID, hosts[1].UserID, hosts[2].UserID}; got[0] != 2 || got[1] != 3 || got[2] != 1 {
t.Fatalf("host salary wallet desc order mismatch: %v", got)
}
if page := paginateHostProfiles(hosts, 2, 2); len(page) != 1 || page[0].UserID != 1 {
t.Fatalf("host salary wallet pagination mismatch: %+v", page)
}
}

View File

@ -1,5 +1,47 @@
package hostsalarysettlement
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
type settlementUserID int64
func (v *settlementUserID) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
raw = strings.TrimSpace(text)
if raw == "" {
*v = 0
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid settlement user id %q", raw)
}
*v = settlementUserID(parsed)
return nil
}
func settlementUserIDValues(items []settlementUserID) []int64 {
out := make([]int64, 0, len(items))
for _, item := range items {
out = append(out, int64(item))
}
return out
}
type userDTO struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
@ -7,11 +49,56 @@ type userDTO struct {
Avatar string `json:"avatar"`
}
type pendingDTO struct {
UserID string `json:"user_id"`
User userDTO `json:"user"`
AgencyOwnerUserID string `json:"agency_owner_user_id"`
AgencyOwner userDTO `json:"agency_owner"`
Role string `json:"role"`
CycleKey string `json:"cycle_key"`
RegionID int64 `json:"region_id"`
RegionName string `json:"region_name"`
PolicyID uint64 `json:"policy_id"`
PolicyName string `json:"policy_name"`
SettlementType string `json:"settlement_type"`
TriggerMode string `json:"trigger_mode"`
LevelNo int32 `json:"level_no"`
RequiredDiamonds int64 `json:"required_diamonds"`
TotalDiamonds int64 `json:"total_diamonds"`
HostSalaryUSDMinorTarget int64 `json:"host_salary_usd_minor_target"`
HostSalaryUSDTarget string `json:"host_salary_usd_target"`
HostSalaryUSDMinorDelta int64 `json:"host_salary_usd_minor_delta"`
HostSalaryUSDDelta string `json:"host_salary_usd_delta"`
HostCoinRewardTarget int64 `json:"host_coin_reward_target"`
HostCoinRewardDelta int64 `json:"host_coin_reward_delta"`
AgencySalaryUSDMinorTarget int64 `json:"agency_salary_usd_minor_target"`
AgencySalaryUSDTarget string `json:"agency_salary_usd_target"`
AgencySalaryUSDMinorDelta int64 `json:"agency_salary_usd_minor_delta"`
AgencySalaryUSDDelta string `json:"agency_salary_usd_delta"`
ResidualUSDMinorDelta int64 `json:"residual_usd_minor_delta"`
ResidualUSDDelta string `json:"residual_usd_delta"`
TotalUSDMinorDelta int64 `json:"total_usd_minor_delta"`
TotalUSDDelta string `json:"total_usd_delta"`
SettledLevelNo int32 `json:"settled_level_no"`
SettledHostSalaryUSDMinor int64 `json:"settled_host_salary_usd_minor"`
SettledHostSalaryUSD string `json:"settled_host_salary_usd"`
SettledHostCoinReward int64 `json:"settled_host_coin_reward"`
SettledAgencySalaryUSDMinor int64 `json:"settled_agency_salary_usd_minor"`
SettledAgencySalaryUSD string `json:"settled_agency_salary_usd"`
LastSettledTotalDiamonds int64 `json:"last_settled_total_diamonds"`
ResidualUSDMinor int64 `json:"residual_usd_minor"`
ResidualUSD string `json:"residual_usd"`
MonthEndClearedAtMS int64 `json:"month_end_cleared_at_ms"`
Status string `json:"status"`
}
type settlementDTO struct {
SettlementID string `json:"settlement_id"`
CommandID string `json:"command_id"`
TransactionID string `json:"transaction_id"`
SettlementType string `json:"settlement_type"`
TriggerMode string `json:"trigger_mode"`
SettlementRole string `json:"settlement_role"`
UserID string `json:"user_id"`
User userDTO `json:"user"`
AgencyOwnerUserID string `json:"agency_owner_user_id"`
@ -44,3 +131,40 @@ type query struct {
StartAtMS int64
EndAtMS int64
}
type pendingQuery struct {
Page int
PageSize int
Role string
CycleKey string
SettlementType string
TriggerMode string
RegionID int64
CountryID int64
CountryCode string
UserID int64
}
type settleRequest struct {
Role string `json:"role"`
CycleKey string `json:"cycle_key"`
SettlementType string `json:"settlement_type"`
TriggerMode string `json:"trigger_mode"`
RegionID int64 `json:"region_id"`
CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code"`
UserIDs []settlementUserID `json:"user_ids"`
}
type settleResultDTO struct {
Role string `json:"role"`
CycleKey string `json:"cycle_key"`
SettlementType string `json:"settlement_type"`
TriggerMode string `json:"trigger_mode"`
RequestedCount int `json:"requested_count"`
ProcessedCount int `json:"processed_count"`
SuccessCount int `json:"success_count"`
SkippedCount int `json:"skipped_count"`
FailureCount int `json:"failure_count"`
Records []settlementDTO `json:"records"`
}

View File

@ -2,22 +2,40 @@ package hostsalarysettlement
import (
"database/sql"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
audit shared.OperationLogger
service *Service
}
func New(walletDB *sql.DB, userDB *sql.DB) *Handler {
return &Handler{service: NewService(walletDB, userDB)}
func New(walletDB *sql.DB, userDB *sql.DB, walletCron walletv1.WalletCronServiceClient, requestTimeout time.Duration, audit shared.OperationLogger) *Handler {
return &Handler{audit: audit, service: NewService(walletDB, userDB, walletCron, requestTimeout)}
}
func (h *Handler) ListPending(c *gin.Context) {
req, ok := parsePendingQuery(c)
if !ok {
return
}
items, total, err := h.service.ListPending(c.Request.Context(), appctx.FromContext(c.Request.Context()), req)
if err != nil {
response.ServerError(c, "获取 Host/Agency 待结算工资失败")
return
}
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
}
func (h *Handler) List(c *gin.Context) {
@ -33,6 +51,37 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total})
}
func (h *Handler) Settle(c *gin.Context) {
var req settleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结算参数不正确")
return
}
rawSettlementType := strings.TrimSpace(req.SettlementType)
rawTriggerMode := strings.TrimSpace(req.TriggerMode)
rawRole := strings.TrimSpace(req.Role)
req = normalizeSettleRequest(req)
if rawRole != "" && normalizeSettlementRole(rawRole) == "" {
response.BadRequest(c, "结算角色不正确")
return
}
if rawSettlementType != "" && normalizeSettlementType(rawSettlementType) == "" {
response.BadRequest(c, "结算类型不正确")
return
}
if rawTriggerMode != "" && normalizeTriggerMode(rawTriggerMode) == "" {
response.BadRequest(c, "触发方式不正确")
return
}
result, err := h.service.Settle(c.Request.Context(), appctx.FromContext(c.Request.Context()), int64(shared.ActorFromContext(c).UserID), req)
if err != nil {
response.ServerError(c, "Host/Agency 工资结算失败")
return
}
shared.OperationLog(c, h.audit, "settle-host-salary", "host_salary_settlement_records", "success", fmt.Sprintf("role=%s settlement_type=%s trigger_mode=%s cycle_key=%s success=%d skipped=%d failed=%d", result.Role, result.SettlementType, result.TriggerMode, result.CycleKey, result.SuccessCount, result.SkippedCount, result.FailureCount))
response.OK(c, result)
}
func parseQuery(c *gin.Context) (query, bool) {
options := shared.ListOptions(c)
userID, ok := optionalInt64(c, "user_id", "userId")
@ -87,6 +136,45 @@ func parseQuery(c *gin.Context) (query, bool) {
return req, true
}
func parsePendingQuery(c *gin.Context) (pendingQuery, bool) {
options := shared.ListOptions(c)
req := pendingQuery{
Page: options.Page,
PageSize: options.PageSize,
Role: normalizeSettlementRole(firstQuery(c, "role", "settlement_role", "settlementRole")),
CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")),
SettlementType: normalizeSettlementType(firstQuery(c, "settlement_type", "settlementType")),
TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")),
CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))),
}
var ok bool
if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok {
response.BadRequest(c, "区域 ID 不正确")
return pendingQuery{}, false
}
if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok {
response.BadRequest(c, "国家 ID 不正确")
return pendingQuery{}, false
}
if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok {
response.BadRequest(c, "主播用户 ID 不正确")
return pendingQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "role", "settlement_role", "settlementRole")); raw != "" && strings.ToLower(raw) != "all" && req.Role == "" {
response.BadRequest(c, "结算角色不正确")
return pendingQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "settlement_type", "settlementType")); raw != "" && strings.ToLower(raw) != "all" && req.SettlementType == "" {
response.BadRequest(c, "结算类型不正确")
return pendingQuery{}, false
}
if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && strings.ToLower(raw) != "all" && req.TriggerMode == "" {
response.BadRequest(c, "触发方式不正确")
return pendingQuery{}, false
}
return normalizePendingQuery(req), true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {

View File

@ -11,5 +11,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
return
}
protected.GET("/admin/host-salary-settlements/pending", middleware.RequirePermission("host-salary-settlement:view"), h.ListPending)
protected.POST("/admin/host-salary-settlements/settle", middleware.RequirePermission("host-salary-settlement:settle"), h.Settle)
protected.GET("/admin/host-salary-settlements", middleware.RequirePermission("host-salary-settlement:view"), h.List)
}

View File

@ -3,16 +3,39 @@ package hostsalarysettlement
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"math/big"
"sort"
"strconv"
"strings"
"time"
walletv1 "hyapp.local/api/proto/wallet/v1"
"google.golang.org/grpc"
)
const maxPageSize = 100
const (
maxPageSize = 100
settlementTypeDaily = "daily"
settlementTypeHalfMonth = "half_month"
settlementTypeMonthEnd = "month_end"
triggerAutomatic = "automatic"
triggerManual = "manual"
settlementRoleAll = "all"
settlementRoleHost = "host"
settlementRoleAgency = "agency"
statusPending = "pending"
)
type Service struct {
walletDB *sql.DB
userDB *sql.DB
walletDB *sql.DB
userDB *sql.DB
walletCron walletCronClient
requestTimeout time.Duration
}
type userProfile struct {
@ -22,13 +45,321 @@ type userProfile struct {
Avatar string
}
func NewService(walletDB *sql.DB, userDB *sql.DB) *Service {
return &Service{walletDB: walletDB, userDB: userDB}
type walletCronClient interface {
ProcessHostSalaryDailySettlementBatch(ctx context.Context, in *walletv1.CronBatchRequest, opts ...grpc.CallOption) (*walletv1.CronBatchResponse, error)
ProcessHostSalaryHalfMonthSettlementBatch(ctx context.Context, in *walletv1.CronBatchRequest, opts ...grpc.CallOption) (*walletv1.CronBatchResponse, error)
ProcessHostSalaryMonthEndBatch(ctx context.Context, in *walletv1.CronBatchRequest, opts ...grpc.CallOption) (*walletv1.CronBatchResponse, error)
}
type hostPolicy struct {
PolicyID uint64
Name string
RegionID int64
SettlementMode string
TriggerMode string
ResidualDiamondToUSDRate string
Levels []hostPolicyLevel
}
type hostPolicyLevel struct {
LevelNo int
RequiredDiamonds int64
HostSalaryUSDMinor int64
HostCoinReward int64
AgencySalaryUSDMinor int64
}
type hostProgress struct {
SettledLevelNo int
SettledHostSalaryUSDMinor int64
SettledHostCoinReward int64
SettledAgencySalaryUSDMinor int64
LastSettledTotalDiamonds int64
ResidualUSDMinor int64
MonthEndClearedAtMS int64
}
type hostCandidate struct {
UserID int64
AgencyOwnerUserID int64
Role string
CycleKey string
RegionID int64
RegionName string
SettlementType string
TotalDiamonds int64
Policy hostPolicy
Level hostPolicyLevel
Progress hostProgress
HostSalaryUSDDelta int64
HostCoinRewardDelta int64
AgencySalaryUSDDelta int64
ResidualUSDDelta int64
}
func NewService(walletDB *sql.DB, userDB *sql.DB, walletCron walletCronClient, requestTimeout time.Duration) *Service {
return &Service{walletDB: walletDB, userDB: userDB, walletCron: walletCron, requestTimeout: requestTimeout}
}
func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQuery) ([]pendingDTO, int64, error) {
if err := s.ensureReady(ctx); err != nil {
return nil, 0, err
}
req = normalizePendingQuery(req)
// 待结算列表只做只读重算,不写 progress、不写钱包真正入账必须走 Settle 调 wallet-service。
candidates, err := s.collectPendingCandidates(ctx, strings.TrimSpace(appCode), req, time.Now().UTC().UnixMilli())
if err != nil {
return nil, 0, err
}
sortHostCandidates(candidates)
total := int64(len(candidates))
pageItems := paginateHostCandidates(candidates, req.Page, req.PageSize)
userIDs := make([]int64, 0, len(pageItems)*2)
for _, item := range pageItems {
userIDs = append(userIDs, item.UserID, item.AgencyOwnerUserID)
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, 0, err
}
items := make([]pendingDTO, 0, len(pageItems))
for _, item := range pageItems {
items = append(items, pendingDTOFromCandidate(item, profiles[item.UserID], profiles[item.AgencyOwnerUserID]))
}
return items, total, nil
}
func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID int64, req settleRequest) (settleResultDTO, error) {
if err := s.ensureReady(ctx); err != nil {
return settleResultDTO{}, err
}
req = normalizeSettleRequest(req)
userIDs := uniquePositiveUserIDs(settlementUserIDValues(req.UserIDs))
result := settleResultDTO{
CycleKey: req.CycleKey,
SettlementType: req.SettlementType,
TriggerMode: req.TriggerMode,
Role: req.Role,
RequestedCount: len(userIDs),
Records: []settlementDTO{},
}
if len(userIDs) == 0 {
return result, nil
}
// 后台传来的 user_ids 必须先回到待结算候选里二次校验;客户端不能靠篡改区域或周期结算页面外用户。
candidates, err := s.collectPendingCandidates(ctx, strings.TrimSpace(appCode), pendingQuery{
Page: 1,
PageSize: maxPageSize,
Role: req.Role,
CycleKey: req.CycleKey,
SettlementType: req.SettlementType,
TriggerMode: req.TriggerMode,
RegionID: req.RegionID,
CountryID: req.CountryID,
CountryCode: req.CountryCode,
}, time.Now().UTC().UnixMilli())
if err != nil {
return settleResultDTO{}, err
}
allowed := map[int64]struct{}{}
selected := map[int64]struct{}{}
for _, id := range userIDs {
selected[id] = struct{}{}
}
for _, candidate := range candidates {
if _, ok := selected[candidate.UserID]; ok {
allowed[candidate.UserID] = struct{}{}
}
}
settleIDs := make([]int64, 0, len(allowed))
for _, id := range userIDs {
if _, ok := allowed[id]; ok {
settleIDs = append(settleIDs, id)
}
}
if len(settleIDs) == 0 {
result.SkippedCount = len(userIDs)
return result, nil
}
if s.requestTimeout <= 0 {
s.requestTimeout = 3 * time.Second
}
callCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
// wallet-service 是 Host/Agency 工资账务 owneradmin-server 只传人工选择范围,不复制钱包入账逻辑。
resp, err := s.callWalletSettlement(callCtx, appCode, req, operatorAdminID, settleIDs)
if err != nil {
return settleResultDTO{}, err
}
result.ProcessedCount = int(resp.GetProcessedCount())
result.SuccessCount = int(resp.GetSuccessCount())
result.FailureCount = int(resp.GetFailureCount())
result.SkippedCount = len(userIDs) - len(settleIDs) + result.ProcessedCount - result.SuccessCount - result.FailureCount
if result.SkippedCount < 0 {
result.SkippedCount = 0
}
return result, nil
}
func (s *Service) callWalletSettlement(ctx context.Context, appCode string, req settleRequest, operatorAdminID int64, userIDs []int64) (*walletv1.CronBatchResponse, error) {
if s.walletCron == nil {
return nil, fmt.Errorf("wallet cron client is not configured")
}
batchReq := &walletv1.CronBatchRequest{
RequestId: fmt.Sprintf("admin-host-salary-%d", time.Now().UTC().UnixNano()),
AppCode: strings.TrimSpace(appCode),
RunId: fmt.Sprintf("admin-host-salary-%s-%d", req.CycleKey, time.Now().UTC().UnixNano()),
WorkerId: fmt.Sprintf("admin-%d", operatorAdminID),
BatchSize: int32(len(userIDs)),
TriggerMode: req.TriggerMode,
SettlementRole: req.Role,
CycleKey: req.CycleKey,
UserIds: append([]int64(nil), userIDs...),
}
switch req.SettlementType {
case settlementTypeDaily:
return s.walletCron.ProcessHostSalaryDailySettlementBatch(ctx, batchReq)
case settlementTypeHalfMonth:
return s.walletCron.ProcessHostSalaryHalfMonthSettlementBatch(ctx, batchReq)
case settlementTypeMonthEnd:
return s.walletCron.ProcessHostSalaryMonthEndBatch(ctx, batchReq)
default:
return nil, fmt.Errorf("settlement_type is invalid")
}
}
func (s *Service) collectPendingCandidates(ctx context.Context, appCode string, req pendingQuery, nowMS int64) ([]hostCandidate, error) {
req = normalizePendingQuery(req)
regions, err := s.regionFilter(ctx, appCode, req.RegionID, req.CountryID, req.CountryCode)
if err != nil {
return nil, err
}
regionClause := ""
userClause := ""
progressClause := "AND a.total_diamonds > COALESCE(progress.last_settled_total_diamonds, 0)"
if req.SettlementType == settlementTypeMonthEnd {
progressClause = ""
}
args := []any{appCode, req.CycleKey}
if len(regions) > 0 {
ids := keysInt64FromSet(regions)
regionClause = "AND a.region_id IN (" + placeholders(len(ids)) + ")"
for _, id := range ids {
args = append(args, id)
}
} else if req.RegionID > 0 || req.CountryID > 0 || req.CountryCode != "" {
regionClause = "AND a.region_id = -1"
}
if req.UserID > 0 {
userClause = "AND a.user_id = ?"
args = append(args, req.UserID)
}
args = append(args, nowMS, nowMS)
triggerClause := ""
if req.TriggerMode != "" {
triggerClause = "AND p.settlement_trigger_mode = ?"
args = append(args, req.TriggerMode)
}
modeClause := ""
if req.SettlementType != settlementTypeMonthEnd {
modeClause = "AND p.settlement_mode = ?"
args = append(args, req.SettlementType)
}
rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
SELECT a.user_id, a.cycle_key, a.region_id, a.agency_owner_user_id, a.total_diamonds,
COALESCE(progress.settled_level_no, 0), COALESCE(progress.settled_host_salary_usd_minor, 0),
COALESCE(progress.settled_host_coin_reward, 0), COALESCE(progress.settled_agency_salary_usd_minor, 0),
COALESCE(progress.last_settled_total_diamonds, 0), COALESCE(progress.residual_usd_minor, 0),
COALESCE(progress.month_end_cleared_at_ms, 0)
FROM host_period_diamond_accounts a
LEFT JOIN host_salary_settlement_progress progress
ON progress.app_code = a.app_code AND progress.user_id = a.user_id AND progress.cycle_key = a.cycle_key
WHERE a.app_code = ?
AND a.cycle_key = ?
AND a.total_diamonds > 0
AND COALESCE(progress.month_end_cleared_at_ms, 0) = 0
%s
%s
%s
AND EXISTS (
SELECT 1
FROM host_agency_salary_policies p
WHERE p.app_code = a.app_code
AND p.region_id = a.region_id
AND p.status = 'active'
AND p.effective_from_ms <= ?
AND (p.effective_to_ms = 0 OR p.effective_to_ms > ?)
%s
%s
)
ORDER BY a.updated_at_ms ASC, a.user_id ASC`, regionClause, userClause, progressClause, triggerClause, modeClause), args...)
if err != nil {
return nil, err
}
defer rows.Close()
regionNames, _ := s.regionNames(ctx, appCode)
out := []hostCandidate{}
policyCache := map[string]hostPolicy{}
for rows.Next() {
var item hostCandidate
if err := rows.Scan(
&item.UserID,
&item.CycleKey,
&item.RegionID,
&item.AgencyOwnerUserID,
&item.TotalDiamonds,
&item.Progress.SettledLevelNo,
&item.Progress.SettledHostSalaryUSDMinor,
&item.Progress.SettledHostCoinReward,
&item.Progress.SettledAgencySalaryUSDMinor,
&item.Progress.LastSettledTotalDiamonds,
&item.Progress.ResidualUSDMinor,
&item.Progress.MonthEndClearedAtMS,
); err != nil {
return nil, err
}
cacheKey := fmt.Sprintf("%d:%s:%s", item.RegionID, req.SettlementType, req.TriggerMode)
policy, ok := policyCache[cacheKey]
if !ok {
policy, ok, err = s.resolvePolicy(ctx, appCode, item.RegionID, req.SettlementType, req.TriggerMode, nowMS)
if err != nil {
return nil, err
}
if !ok {
continue
}
policyCache[cacheKey] = policy
}
item.Policy = policy
item.RegionName = regionNames[item.RegionID]
item.Role = req.Role
item.SettlementType = req.SettlementType
item.Level = highestHostLevel(policy.Levels, item.TotalDiamonds)
item.HostSalaryUSDDelta = positiveDelta(item.Level.HostSalaryUSDMinor, item.Progress.SettledHostSalaryUSDMinor)
item.HostCoinRewardDelta = positiveDelta(item.Level.HostCoinReward, item.Progress.SettledHostCoinReward)
item.AgencySalaryUSDDelta = positiveDelta(item.Level.AgencySalaryUSDMinor, item.Progress.SettledAgencySalaryUSDMinor)
if item.AgencyOwnerUserID <= 0 {
item.AgencySalaryUSDDelta = 0
}
if req.SettlementType == settlementTypeMonthEnd {
residualDiamonds := residualDiamondRemainder(item.TotalDiamonds, item.Level)
residualTarget, err := usdMinorFromDiamondRate(residualDiamonds, policy.ResidualDiamondToUSDRate)
if err != nil {
return nil, err
}
item.ResidualUSDDelta = positiveDelta(residualTarget, item.Progress.ResidualUSDMinor)
}
// 待结算按身份拆分Host 只看主播工资/金币/剩余折美元Agency 只看代理工资。
if hostCandidateHasPendingRole(item, req.Role) {
out = append(out, item)
}
}
return out, rows.Err()
}
func (s *Service) List(ctx context.Context, appCode string, req query) ([]settlementDTO, int64, error) {
if s == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
if err := s.ensureReady(ctx); err != nil {
return nil, 0, err
}
req = normalizeQuery(req)
whereSQL, args := settlementWhere(appCode, req)
@ -38,7 +369,7 @@ func (s *Service) List(ctx context.Context, appCode string, req query) ([]settle
return nil, 0, err
}
rows, err := s.walletDB.QueryContext(ctx, `
SELECT settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id,
SELECT settlement_id, command_id, transaction_id, settlement_type, trigger_mode, settlement_role, user_id, agency_owner_user_id,
cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta,
host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta,
status, reason, created_at_ms
@ -64,6 +395,8 @@ func (s *Service) List(ctx context.Context, appCode string, req query) ([]settle
&item.CommandID,
&item.TransactionID,
&item.SettlementType,
&item.TriggerMode,
&item.SettlementRole,
&userID,
&agencyOwnerUserID,
&item.CycleKey,
@ -153,6 +486,130 @@ func settlementWhere(appCode string, req query) (string, []any) {
return "WHERE " + strings.Join(conditions, " AND "), args
}
func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID int64, settlementType string, triggerMode string, nowMS int64) (hostPolicy, bool, error) {
conditions := []string{"app_code = ?", "region_id = ?", "status = 'active'", "effective_from_ms <= ?", "(effective_to_ms = 0 OR effective_to_ms > ?)"}
args := []any{appCode, regionID, nowMS, nowMS}
if triggerMode != "" {
conditions = append(conditions, "settlement_trigger_mode = ?")
args = append(args, triggerMode)
}
if settlementType != settlementTypeMonthEnd {
conditions = append(conditions, "settlement_mode = ?")
args = append(args, settlementType)
}
row := s.walletDB.QueryRowContext(ctx, `
SELECT policy_id, name, region_id, settlement_mode, settlement_trigger_mode, CAST(residual_diamond_to_usd_rate AS CHAR)
FROM host_agency_salary_policies
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY effective_from_ms DESC, policy_id DESC
LIMIT 1`, args...)
var policy hostPolicy
if err := row.Scan(&policy.PolicyID, &policy.Name, &policy.RegionID, &policy.SettlementMode, &policy.TriggerMode, &policy.ResidualDiamondToUSDRate); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return hostPolicy{}, false, nil
}
return hostPolicy{}, false, err
}
levels, err := s.policyLevels(ctx, appCode, policy.PolicyID)
if err != nil {
return hostPolicy{}, false, err
}
policy.Levels = levels
return policy, true, nil
}
func (s *Service) policyLevels(ctx context.Context, appCode string, policyID uint64) ([]hostPolicyLevel, error) {
rows, err := s.walletDB.QueryContext(ctx, `
SELECT level_no, required_diamonds, host_salary_usd_minor, host_coin_reward, agency_salary_usd_minor
FROM host_agency_salary_policy_levels
WHERE app_code = ? AND policy_id = ? AND status = 'active'
ORDER BY required_diamonds ASC, level_no ASC`, appCode, policyID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []hostPolicyLevel
for rows.Next() {
var item hostPolicyLevel
if err := rows.Scan(&item.LevelNo, &item.RequiredDiamonds, &item.HostSalaryUSDMinor, &item.HostCoinReward, &item.AgencySalaryUSDMinor); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Service) regionFilter(ctx context.Context, appCode string, regionID int64, countryID int64, countryCode string) (map[int64]struct{}, error) {
if regionID > 0 {
return map[int64]struct{}{regionID: {}}, nil
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryID > 0 && countryCode == "" {
if err := s.userDB.QueryRowContext(ctx, `SELECT country_code FROM countries WHERE app_code = ? AND country_id = ?`, appCode, countryID).Scan(&countryCode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return map[int64]struct{}{}, nil
}
return nil, err
}
}
if countryCode == "" {
return nil, nil
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT region_id
FROM region_countries
WHERE app_code = ? AND country_code = ? AND status = 'active'`, appCode, countryCode)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]struct{}{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = struct{}{}
}
return out, rows.Err()
}
func (s *Service) regionNames(ctx context.Context, appCode string) (map[int64]string, error) {
rows, err := s.userDB.QueryContext(ctx, `SELECT region_id, name FROM regions WHERE app_code = ?`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]string{}
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
out[id] = name
}
return out, rows.Err()
}
func (s *Service) ensureReady(ctx context.Context) error {
if s == nil || s.walletDB == nil || s.userDB == nil {
return fmt.Errorf("host salary settlement dependencies are not configured")
}
// 老库没有 trigger_mode 时,查询和手动结算记录都会失败;这里补齐列,历史记录默认视为 automatic。
if _, err := s.walletDB.ExecContext(ctx, `
ALTER TABLE host_salary_settlement_records
ADD COLUMN trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '触发方式automatic/manual' AFTER settlement_type`); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
if _, err := s.walletDB.ExecContext(ctx, `
ALTER TABLE host_salary_settlement_records
ADD COLUMN settlement_role VARCHAR(24) NOT NULL DEFAULT 'all' COMMENT '结算身份all/host/agency' AFTER trigger_mode`); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
return nil
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
if s == nil || s.userDB == nil || len(userIDs) == 0 {
return map[int64]userProfile{}, nil
@ -204,6 +661,53 @@ func normalizeQuery(req query) query {
return req
}
func normalizePendingQuery(req pendingQuery) pendingQuery {
req.CycleKey = strings.TrimSpace(req.CycleKey)
req.Role = normalizeSettlementRole(req.Role)
if req.Role == "" {
req.Role = settlementRoleHost
}
req.SettlementType = normalizeSettlementType(req.SettlementType)
if req.SettlementType == "" {
req.SettlementType = settlementTypeHalfMonth
}
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
if req.TriggerMode == "" {
req.TriggerMode = triggerManual
}
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
if req.CycleKey == "" {
req.CycleKey = currentMonthCycleKey(time.Now().UTC())
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 50
}
if req.PageSize > maxPageSize {
req.PageSize = maxPageSize
}
return req
}
func normalizeSettleRequest(req settleRequest) settleRequest {
req.CycleKey = strings.TrimSpace(req.CycleKey)
req.SettlementType = normalizeSettlementType(req.SettlementType)
if req.SettlementType == "" {
req.SettlementType = settlementTypeHalfMonth
}
req.TriggerMode = normalizeTriggerMode(req.TriggerMode)
if req.TriggerMode == "" {
req.TriggerMode = triggerManual
}
req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode))
if req.CycleKey == "" {
req.CycleKey = currentMonthCycleKey(time.Now().UTC())
}
return req
}
func normalizeSettlementType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
@ -219,6 +723,32 @@ func normalizeSettlementType(value string) string {
}
}
func normalizeTriggerMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case triggerAutomatic:
return triggerAutomatic
case triggerManual:
return triggerManual
default:
return ""
}
}
func normalizeSettlementRole(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", settlementRoleAll:
return ""
case settlementRoleHost:
return settlementRoleHost
case settlementRoleAgency:
return settlementRoleAgency
default:
return ""
}
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
@ -282,6 +812,173 @@ func formatUSDMinor(value int64) string {
return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10)
}
func pendingDTOFromCandidate(item hostCandidate, host userProfile, agency userProfile) pendingDTO {
userID := strconv.FormatInt(item.UserID, 10)
agencyID := formatOptionalUserID(item.AgencyOwnerUserID)
hostSalaryUSDDelta := item.HostSalaryUSDDelta
hostCoinRewardDelta := item.HostCoinRewardDelta
agencySalaryUSDDelta := item.AgencySalaryUSDDelta
residualUSDDelta := item.ResidualUSDDelta
switch item.Role {
case settlementRoleAgency:
hostSalaryUSDDelta = 0
hostCoinRewardDelta = 0
residualUSDDelta = 0
case settlementRoleHost:
agencySalaryUSDDelta = 0
}
totalUSDDelta, _ := checkedAdd(hostSalaryUSDDelta, agencySalaryUSDDelta, residualUSDDelta)
return pendingDTO{
UserID: userID,
User: userDTOFromProfile(userID, host),
AgencyOwnerUserID: agencyID,
AgencyOwner: userDTOFromProfile(agencyID, agency),
Role: item.Role,
CycleKey: item.CycleKey,
RegionID: item.RegionID,
RegionName: item.RegionName,
PolicyID: item.Policy.PolicyID,
PolicyName: item.Policy.Name,
SettlementType: item.SettlementType,
TriggerMode: item.Policy.TriggerMode,
LevelNo: int32(item.Level.LevelNo),
RequiredDiamonds: item.Level.RequiredDiamonds,
TotalDiamonds: item.TotalDiamonds,
HostSalaryUSDMinorTarget: item.Level.HostSalaryUSDMinor,
HostSalaryUSDTarget: formatUSDMinor(item.Level.HostSalaryUSDMinor),
HostSalaryUSDMinorDelta: hostSalaryUSDDelta,
HostSalaryUSDDelta: formatUSDMinor(hostSalaryUSDDelta),
HostCoinRewardTarget: item.Level.HostCoinReward,
HostCoinRewardDelta: hostCoinRewardDelta,
AgencySalaryUSDMinorTarget: item.Level.AgencySalaryUSDMinor,
AgencySalaryUSDTarget: formatUSDMinor(item.Level.AgencySalaryUSDMinor),
AgencySalaryUSDMinorDelta: agencySalaryUSDDelta,
AgencySalaryUSDDelta: formatUSDMinor(agencySalaryUSDDelta),
ResidualUSDMinorDelta: residualUSDDelta,
ResidualUSDDelta: formatUSDMinor(residualUSDDelta),
TotalUSDMinorDelta: totalUSDDelta,
TotalUSDDelta: formatUSDMinor(totalUSDDelta),
SettledLevelNo: int32(item.Progress.SettledLevelNo),
SettledHostSalaryUSDMinor: item.Progress.SettledHostSalaryUSDMinor,
SettledHostSalaryUSD: formatUSDMinor(item.Progress.SettledHostSalaryUSDMinor),
SettledHostCoinReward: item.Progress.SettledHostCoinReward,
SettledAgencySalaryUSDMinor: item.Progress.SettledAgencySalaryUSDMinor,
SettledAgencySalaryUSD: formatUSDMinor(item.Progress.SettledAgencySalaryUSDMinor),
LastSettledTotalDiamonds: item.Progress.LastSettledTotalDiamonds,
ResidualUSDMinor: item.Progress.ResidualUSDMinor,
ResidualUSD: formatUSDMinor(item.Progress.ResidualUSDMinor),
MonthEndClearedAtMS: item.Progress.MonthEndClearedAtMS,
Status: statusPending,
}
}
func hostCandidateHasPendingRole(item hostCandidate, role string) bool {
switch role {
case settlementRoleAgency:
return item.AgencySalaryUSDDelta > 0
case settlementRoleHost:
return item.HostSalaryUSDDelta > 0 || item.HostCoinRewardDelta > 0 || item.ResidualUSDDelta > 0
default:
return item.HostSalaryUSDDelta > 0 || item.HostCoinRewardDelta > 0 || item.AgencySalaryUSDDelta > 0 || item.ResidualUSDDelta > 0
}
}
func sortHostCandidates(items []hostCandidate) {
sort.SliceStable(items, func(i, j int) bool {
if items[i].CycleKey != items[j].CycleKey {
return items[i].CycleKey > items[j].CycleKey
}
if items[i].RegionID != items[j].RegionID {
return items[i].RegionID < items[j].RegionID
}
return items[i].UserID < items[j].UserID
})
}
func paginateHostCandidates(items []hostCandidate, page int, pageSize int) []hostCandidate {
start := offset(page, pageSize)
if start >= len(items) {
return []hostCandidate{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func keysInt64FromSet(values map[int64]struct{}) []int64 {
out := make([]int64, 0, len(values))
for id := range values {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func highestHostLevel(levels []hostPolicyLevel, totalDiamonds int64) hostPolicyLevel {
var selected hostPolicyLevel
for _, level := range levels {
if level.RequiredDiamonds <= totalDiamonds {
selected = level
}
}
return selected
}
func residualDiamondRemainder(totalDiamonds int64, level hostPolicyLevel) int64 {
if level.LevelNo <= 0 || totalDiamonds <= level.RequiredDiamonds {
return 0
}
return totalDiamonds - level.RequiredDiamonds
}
func usdMinorFromDiamondRate(diamonds int64, rate string) (int64, error) {
if diamonds <= 0 || strings.TrimSpace(rate) == "" {
return 0, nil
}
rat, ok := new(big.Rat).SetString(strings.TrimSpace(rate))
if !ok || rat.Sign() < 0 {
return 0, fmt.Errorf("residual diamond rate is invalid")
}
value := new(big.Rat).Mul(new(big.Rat).SetInt64(diamonds), rat)
value.Mul(value, big.NewRat(100, 1))
result := new(big.Int).Quo(value.Num(), value.Denom())
if !result.IsInt64() || result.Int64() > math.MaxInt64 {
return 0, fmt.Errorf("residual diamond amount overflow")
}
return result.Int64(), nil
}
func positiveDelta(target int64, current int64) int64 {
if target <= current {
return 0
}
return target - current
}
func checkedAdd(values ...int64) (int64, error) {
var total int64
for _, value := range values {
if value > 0 && total > math.MaxInt64-value {
return 0, fmt.Errorf("amount overflow")
}
total += value
}
return total, nil
}
func currentMonthCycleKey(now time.Time) string {
return now.UTC().Format("2006-01")
}
func offset(page int, pageSize int) int {
if page <= 1 {
return 0

View File

@ -62,7 +62,7 @@ type claimDTO struct {
ClaimID string `json:"claim_id"`
AppCode string `json:"app_code"`
CycleKey string `json:"cycle_key"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
RewardType string `json:"reward_type"`
CommandID string `json:"command_id"`
@ -82,8 +82,28 @@ type claimDTO struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type summaryDTO struct {
AppCode string `json:"app_code"`
CycleKey string `json:"cycle_key"`
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
InviteCount int64 `json:"invite_count"`
ValidInviteCount int64 `json:"valid_invite_count"`
TotalRechargeCoinAmount int64 `json:"total_recharge_coin_amount"`
TotalRewardCoinAmount int64 `json:"total_reward_coin_amount"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Invitees []inviteeDTO `json:"invitees"`
}
type inviteeDTO struct {
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
InvitedAtMS int64 `json:"invited_at_ms"`
}
type userDTO struct {
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
@ -171,6 +191,39 @@ func (h *Handler) ListClaims(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ListSummaries(c *gin.Context) {
options := shared.ListOptions(c)
userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
if !ok {
response.ServerError(c, "查询用户信息失败")
return
}
if options.Keyword != "" && !matched {
response.OK(c, response.Page{Items: []summaryDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
resp, err := h.activity.ListInviteActivityRewardSummaries(c.Request.Context(), &activityv1.ListInviteActivityRewardSummariesRequest{
Meta: h.meta(c),
UserId: userID,
CycleKey: strings.TrimSpace(c.Query("cycle_key")),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取邀请活动列表失败")
return
}
items := make([]summaryDTO, 0, len(resp.GetSummaries()))
for _, summary := range resp.GetSummaries() {
items = append(items, summaryFromProto(summary))
}
if err := h.fillSummaryUsers(c.Request.Context(), items); err != nil {
response.ServerError(c, "补全用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
@ -188,24 +241,20 @@ func (h *Handler) resolveUserID(ctx context.Context, keyword string) (int64, boo
return 0, false, true
}
appCode := appctx.FromContext(ctx)
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
args...,
)
if err != nil {
return 0, false, false
@ -222,35 +271,17 @@ func (h *Handler) resolveUserID(ctx context.Context, keyword string) (int64, boo
}
func (h *Handler) fillUsers(ctx context.Context, claims []claimDTO) error {
if h.userDB == nil || len(claims) == 0 {
if len(claims) == 0 {
return nil
}
ids := collectUserIDs(claims)
if len(ids) == 0 {
return nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
args...,
)
users, err := h.loadUsers(ctx, ids)
if err != nil {
return err
}
defer rows.Close()
users := make(map[int64]userDTO, len(ids))
for rows.Next() {
var user userDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
return err
}
users[user.UserID] = user
}
if err := rows.Err(); err != nil {
return err
}
for index := range claims {
if user, ok := users[claims[index].UserID]; ok {
claims[index].User = &user
@ -261,6 +292,65 @@ func (h *Handler) fillUsers(ctx context.Context, claims []claimDTO) error {
return nil
}
func (h *Handler) fillSummaryUsers(ctx context.Context, summaries []summaryDTO) error {
if len(summaries) == 0 {
return nil
}
ids := collectSummaryUserIDs(summaries)
if len(ids) == 0 {
return nil
}
users, err := h.loadUsers(ctx, ids)
if err != nil {
return err
}
for summaryIndex := range summaries {
if user, ok := users[summaries[summaryIndex].UserID]; ok {
summaries[summaryIndex].User = &user
} else {
summaries[summaryIndex].User = &userDTO{UserID: summaries[summaryIndex].UserID}
}
for inviteeIndex := range summaries[summaryIndex].Invitees {
userID := summaries[summaryIndex].Invitees[inviteeIndex].UserID
if user, ok := users[userID]; ok {
summaries[summaryIndex].Invitees[inviteeIndex].User = &user
continue
}
summaries[summaryIndex].Invitees[inviteeIndex].User = &userDTO{UserID: userID}
}
}
return nil
}
func (h *Handler) loadUsers(ctx context.Context, ids []int64) (map[int64]userDTO, error) {
if h.userDB == nil || len(ids) == 0 {
return map[int64]userDTO{}, nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
users := make(map[int64]userDTO, len(ids))
for rows.Next() {
var user userDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
return nil, err
}
users[user.UserID] = user
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
func configFromProto(config *activityv1.InviteActivityRewardConfig) configDTO {
if config == nil {
return configDTO{Tiers: []tierDTO{}}
@ -328,6 +418,38 @@ func claimFromProto(claim *activityv1.InviteActivityRewardClaim) claimDTO {
}
}
func summaryFromProto(summary *activityv1.InviteActivityRewardSummary) summaryDTO {
if summary == nil {
return summaryDTO{Invitees: []inviteeDTO{}}
}
invitees := make([]inviteeDTO, 0, len(summary.GetInvitees()))
for _, invitee := range summary.GetInvitees() {
invitees = append(invitees, inviteeFromProto(invitee))
}
return summaryDTO{
AppCode: summary.GetAppCode(),
CycleKey: summary.GetCycleKey(),
UserID: summary.GetUserId(),
InviteCount: summary.GetInviteCount(),
ValidInviteCount: summary.GetValidInviteCount(),
TotalRechargeCoinAmount: summary.GetTotalRechargeCoinAmount(),
TotalRewardCoinAmount: summary.GetTotalRewardCoinAmount(),
UpdatedAtMS: summary.GetUpdatedAtMs(),
Invitees: invitees,
}
}
func inviteeFromProto(invitee *activityv1.InviteActivityRewardInviteeSummary) inviteeDTO {
if invitee == nil {
return inviteeDTO{}
}
return inviteeDTO{
UserID: invitee.GetInvitedUserId(),
RechargeCoinAmount: invitee.GetRechargeCoinAmount(),
InvitedAtMS: invitee.GetInvitedAtMs(),
}
}
func collectUserIDs(claims []claimDTO) []int64 {
seen := make(map[int64]struct{}, len(claims))
ids := make([]int64, 0, len(claims))
@ -344,6 +466,30 @@ func collectUserIDs(claims []claimDTO) []int64 {
return ids
}
func collectSummaryUserIDs(summaries []summaryDTO) []int64 {
seen := make(map[int64]struct{}, len(summaries))
ids := make([]int64, 0, len(summaries))
for _, summary := range summaries {
if summary.UserID > 0 {
if _, ok := seen[summary.UserID]; !ok {
seen[summary.UserID] = struct{}{}
ids = append(ids, summary.UserID)
}
}
for _, invitee := range summary.Invitees {
if invitee.UserID <= 0 {
continue
}
if _, ok := seen[invitee.UserID]; ok {
continue
}
seen[invitee.UserID] = struct{}{}
ids = append(ids, invitee.UserID)
}
}
return ids
}
func int64Args(ids []int64) []any {
args := make([]any, 0, len(ids))
for _, id := range ids {

View File

@ -12,5 +12,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/activity/invite-reward/config", middleware.RequirePermission("invite-activity-reward:view"), h.GetConfig)
protected.PUT("/admin/activity/invite-reward/config", middleware.RequirePermission("invite-activity-reward:update"), h.UpdateConfig)
protected.GET("/admin/activity/invite-reward/summaries", middleware.RequirePermission("invite-activity-reward:view"), h.ListSummaries)
protected.GET("/admin/activity/invite-reward/claims", middleware.RequirePermission("invite-activity-reward:view"), h.ListClaims)
}

View File

@ -2,6 +2,7 @@ package luckygift
import (
"context"
"fmt"
"math"
"strings"
"time"
@ -227,12 +228,17 @@ func (h *Handler) activityContext(c *gin.Context) (context.Context, context.Canc
func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
stages := make([]*activityv1.LuckyGiftRuleStage, 0, len(req.Stages))
for _, stage := range req.Stages {
stageName := strings.TrimSpace(stage.Stage)
tierIDCounts := make(map[string]int, len(stage.Tiers))
tiers := make([]*activityv1.LuckyGiftRuleTier, 0, len(stage.Tiers))
for _, tier := range stage.Tiers {
multiplierPPM := multiplierToPPM(tier.Multiplier)
tiers = append(tiers, &activityv1.LuckyGiftRuleTier{
Stage: strings.TrimSpace(stage.Stage),
TierId: strings.TrimSpace(tier.TierID),
MultiplierPpm: multiplierToPPM(tier.Multiplier),
Stage: stageName,
// 奖档 ID 是 activity-service 持久化主键的一部分不能让运营手工维护admin-server 按阶段和倍率生成稳定 ID
// 同一阶段同倍率出现多行时追加序号,保证进入 activity-service 前已经满足唯一性。
TierId: generatedLuckyGiftTierID(stageName, multiplierPPM, tierIDCounts),
MultiplierPpm: multiplierPPM,
BaseWeightPpm: percentToPPM(tier.ProbabilityPercent),
RewardSource: strings.TrimSpace(tier.RewardSource),
HighWaterOnly: tier.HighWaterOnly,
@ -241,7 +247,7 @@ func configToProto(req configRequest) *activityv1.LuckyGiftRuleConfig {
})
}
stages = append(stages, &activityv1.LuckyGiftRuleStage{
Stage: strings.TrimSpace(stage.Stage),
Stage: stageName,
Tiers: tiers,
})
}
@ -317,6 +323,29 @@ func configFromProto(config *activityv1.LuckyGiftRuleConfig) configDTO {
}
}
func generatedLuckyGiftTierID(stage string, multiplierPPM int64, counts map[string]int) string {
baseID := luckyGiftTierIDBase(stage, multiplierPPM)
counts[baseID]++
if counts[baseID] == 1 {
return baseID
}
return fmt.Sprintf("%s_%d", baseID, counts[baseID])
}
func luckyGiftTierIDBase(stage string, multiplierPPM int64) string {
stage = strings.TrimSpace(stage)
if multiplierPPM <= 0 {
return stage + "_none"
}
whole := multiplierPPM / 1_000_000
fraction := multiplierPPM % 1_000_000
if fraction == 0 {
return fmt.Sprintf("%s_%dx", stage, whole)
}
fractionText := strings.TrimRight(fmt.Sprintf("%06d", fraction), "0")
return fmt.Sprintf("%s_%d_%sx", stage, whole, fractionText)
}
func percentToPPM(value float64) int64 {
return int64(math.Round(value * 10_000))
}

View File

@ -64,6 +64,32 @@ func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
}
}
func TestConfigToProtoGeneratesUniqueTierIDs(t *testing.T) {
config := configToProto(configRequest{
PoolID: "lucky",
Stages: []stageDTO{
{
Stage: "advanced",
Tiers: []tierDTO{
{TierID: "manual_one", Multiplier: 0, ProbabilityPercent: 50, RewardSource: "base_rtp", Enabled: true},
{TierID: "manual_two", Multiplier: 5, ProbabilityPercent: 25, RewardSource: "base_rtp", Enabled: true},
{TierID: "manual_two", Multiplier: 5, ProbabilityPercent: 25, RewardSource: "base_rtp", Enabled: true},
{TierID: "manual_decimal", Multiplier: 0.5, ProbabilityPercent: 0, RewardSource: "base_rtp"},
},
},
},
})
tiers := config.GetStages()[0].GetTiers()
got := []string{tiers[0].GetTierId(), tiers[1].GetTierId(), tiers[2].GetTierId(), tiers[3].GetTierId()}
want := []string{"advanced_none", "advanced_5x", "advanced_5x_2", "advanced_0_5x"}
for index := range want {
if got[index] != want[index] {
t.Fatalf("tier id[%d] = %q, want %q; all=%v", index, got[index], want[index], got)
}
}
}
func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
config := configFromProto(&activityv1.LuckyGiftRuleConfig{
AppCode: "hyapp",

View File

@ -15,8 +15,8 @@ type rechargeBillDTO struct {
RechargeType string `json:"rechargeType"`
Status string `json:"status"`
ExternalRef string `json:"externalRef"`
UserID int64 `json:"userId"`
SellerUserID int64 `json:"sellerUserId"`
UserID int64 `json:"userId,string"`
SellerUserID int64 `json:"sellerUserId,string"`
SellerRegionID int64 `json:"sellerRegionId"`
TargetRegionID int64 `json:"targetRegionId"`
PolicyID int64 `json:"policyId"`

View File

@ -1,6 +1,10 @@
package prettyid
import (
"context"
"encoding/json"
"errors"
"math"
"strconv"
"strings"
@ -10,8 +14,22 @@ import (
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const internalUserIDMinDigits = 15
var (
errGrantTargetInvalid = errors.New("请输入用户 ID 或短号")
errGrantTargetNotFound = errors.New("用户不存在")
)
type grantTargetResolver interface {
GetUser(ctx context.Context, req userclient.GetUserRequest) (*userclient.User, error)
ResolveDisplayUserID(ctx context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error)
}
type Handler struct {
user userclient.Client
audit shared.OperationLogger
@ -191,12 +209,12 @@ func (h *Handler) Grant(c *gin.Context) {
response.BadRequest(c, "发放参数不正确")
return
}
targetUserID, ok := parseFlexibleInt64(req.TargetUserID)
if !ok {
response.BadRequest(c, "target_user_id 参数不正确")
targetUserID, targetKind, err := resolveGrantTargetUserID(c.Request.Context(), h.user, middleware.CurrentRequestID(c), req.TargetUserID)
if err != nil {
response.BadRequest(c, err.Error())
return
}
// 后台发放允许 target_user_id 由 JS 以数字或字符串提交,这里转成 int64 后把业务校验交给 user-service
// 后台发放最终仍只把内部 user_id 传给 user-service短号/靓号只作为后台输入别名,避免运营把 6 位短号误当成内部 ID 导致 NotFound
result, err := h.user.AdminGrantPrettyDisplayID(c.Request.Context(), userclient.AdminGrantPrettyDisplayIDRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
@ -210,7 +228,7 @@ func (h *Handler) Grant(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "grant-pretty-id", "pretty_display_ids", result.PrettyID, "success", "target_user_id="+strconv.FormatInt(targetUserID, 10))
shared.OperationLogWithResourceID(c, h.audit, "grant-pretty-id", "pretty_display_ids", result.PrettyID, "success", "target_user_id="+strconv.FormatInt(targetUserID, 10)+" target_kind="+targetKind)
response.Created(c, result)
}
@ -272,18 +290,86 @@ func optionalInt64Query(c *gin.Context, name string) (int64, bool) {
return value, err == nil && value > 0
}
func parseFlexibleInt64(raw any) (int64, bool) {
// JSON 大整数在前端经常以字符串提交,数字提交时也必须是精确整数,不能接受小数或负数。
func resolveGrantTargetUserID(ctx context.Context, resolver grantTargetResolver, requestID string, raw any) (int64, string, error) {
keyword, ok := grantTargetKeyword(raw)
if !ok || resolver == nil {
return 0, "", errGrantTargetInvalid
}
numericID, numericOK := parsePositiveInt64(keyword)
// 内部 user_id 是雪花长整型,运营常用的短 ID 是 6 位左右;短输入优先走展示号解析,长输入优先保持旧的内部 ID 语义。
userIDChecked := false
if numericOK && len(strings.TrimLeft(keyword, "0")) >= internalUserIDMinDigits {
userIDChecked = true
if found, err := grantTargetByUserID(ctx, resolver, requestID, numericID); err != nil {
return 0, "", err
} else if found {
return numericID, "user_id", nil
}
}
if identity, err := resolver.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
RequestID: requestID,
Caller: "admin-server",
DisplayUserID: keyword,
}); err != nil {
if !isGRPCNotFound(err) {
return 0, "", err
}
} else if identity != nil && identity.UserID > 0 {
return identity.UserID, "display_user_id", nil
}
if numericOK && !userIDChecked {
if found, err := grantTargetByUserID(ctx, resolver, requestID, numericID); err != nil {
return 0, "", err
} else if found {
return numericID, "user_id", nil
}
}
return 0, "", errGrantTargetNotFound
}
func grantTargetByUserID(ctx context.Context, resolver grantTargetResolver, requestID string, userID int64) (bool, error) {
// GetUser 只用于确认内部 ID 存在;真实发放仍在 AdminGrantPrettyDisplayID 事务里完成,避免这里承担写语义。
user, err := resolver.GetUser(ctx, userclient.GetUserRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: userID,
})
if err != nil {
if isGRPCNotFound(err) {
return false, nil
}
return false, err
}
return user != nil && user.UserID > 0, nil
}
func grantTargetKeyword(raw any) (string, bool) {
// JSON 入口可能收到字符串、数字或测试里的 json.Number统一转成精确文本后再决定按内部 ID 还是短号解析。
switch value := raw.(type) {
case float64:
if value <= 0 || value != float64(int64(value)) {
return 0, false
if value <= 0 || value != math.Trunc(value) || value > float64(math.MaxInt64) {
return "", false
}
return int64(value), true
return strconv.FormatInt(int64(value), 10), true
case json.Number:
text := strings.TrimSpace(value.String())
if _, ok := parsePositiveInt64(text); !ok {
return "", false
}
return text, true
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil && parsed > 0
text := strings.TrimSpace(value)
return text, text != ""
default:
return 0, false
return "", false
}
}
func parsePositiveInt64(text string) (int64, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64)
return parsed, err == nil && parsed > 0
}
func isGRPCNotFound(err error) bool {
return status.Code(err) == codes.NotFound
}

View File

@ -0,0 +1,127 @@
package prettyid
import (
"context"
"reflect"
"strconv"
"testing"
"hyapp-admin-server/internal/integration/userclient"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type fakeGrantTargetResolver struct {
users map[int64]*userclient.User
displays map[string]*userclient.UserIdentity
calls []string
}
func (f *fakeGrantTargetResolver) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) {
f.calls = append(f.calls, "get:"+strconv.FormatInt(req.UserID, 10))
if user := f.users[req.UserID]; user != nil {
return user, nil
}
return nil, status.Error(codes.NotFound, "user not found")
}
func (f *fakeGrantTargetResolver) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) {
f.calls = append(f.calls, "resolve:"+req.DisplayUserID)
if identity := f.displays[req.DisplayUserID]; identity != nil {
return identity, nil
}
return nil, status.Error(codes.NotFound, "display_user_id not found")
}
func TestResolveGrantTargetUserIDUsesShortDisplayID(t *testing.T) {
resolver := &fakeGrantTargetResolver{
users: map[int64]*userclient.User{},
displays: map[string]*userclient.UserIdentity{
"163001": {UserID: 312899006709637120, DisplayUserID: "163001"},
},
}
userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "163001")
if err != nil {
t.Fatalf("resolveGrantTargetUserID returned error: %v", err)
}
if userID != 312899006709637120 || kind != "display_user_id" {
t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind)
}
if want := []string{"resolve:163001"}; !reflect.DeepEqual(resolver.calls, want) {
t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want)
}
}
func TestResolveGrantTargetUserIDKeepsLongInternalID(t *testing.T) {
resolver := &fakeGrantTargetResolver{
users: map[int64]*userclient.User{
312899006709637120: {UserID: 312899006709637120, DisplayUserID: "163001"},
},
displays: map[string]*userclient.UserIdentity{},
}
userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "312899006709637120")
if err != nil {
t.Fatalf("resolveGrantTargetUserID returned error: %v", err)
}
if userID != 312899006709637120 || kind != "user_id" {
t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind)
}
if want := []string{"get:312899006709637120"}; !reflect.DeepEqual(resolver.calls, want) {
t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want)
}
}
func TestResolveGrantTargetUserIDFallsBackForLongDisplayID(t *testing.T) {
resolver := &fakeGrantTargetResolver{
users: map[int64]*userclient.User{},
displays: map[string]*userclient.UserIdentity{
"312899006709637120": {UserID: 411, DisplayUserID: "312899006709637120"},
},
}
userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "312899006709637120")
if err != nil {
t.Fatalf("resolveGrantTargetUserID returned error: %v", err)
}
if userID != 411 || kind != "display_user_id" {
t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind)
}
if want := []string{"get:312899006709637120", "resolve:312899006709637120"}; !reflect.DeepEqual(resolver.calls, want) {
t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want)
}
}
func TestResolveGrantTargetUserIDAcceptsPrettyDisplayID(t *testing.T) {
resolver := &fakeGrantTargetResolver{
users: map[int64]*userclient.User{},
displays: map[string]*userclient.UserIdentity{
"VIP2026": {UserID: 88, DisplayUserID: "VIP2026"},
},
}
userID, kind, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "VIP2026")
if err != nil {
t.Fatalf("resolveGrantTargetUserID returned error: %v", err)
}
if userID != 88 || kind != "display_user_id" {
t.Fatalf("target mismatch: userID=%d kind=%s", userID, kind)
}
if want := []string{"resolve:VIP2026"}; !reflect.DeepEqual(resolver.calls, want) {
t.Fatalf("calls mismatch: got %v want %v", resolver.calls, want)
}
}
func TestResolveGrantTargetUserIDReportsMissingTarget(t *testing.T) {
resolver := &fakeGrantTargetResolver{
users: map[int64]*userclient.User{},
displays: map[string]*userclient.UserIdentity{},
}
_, _, err := resolveGrantTargetUserID(context.Background(), resolver, "req", "163001")
if err != errGrantTargetNotFound {
t.Fatalf("error mismatch: got %v want %v", err, errGrantTargetNotFound)
}
}

View File

@ -56,7 +56,7 @@ type packetDTO struct {
AppCode string `json:"app_code"`
PacketID string `json:"packet_id"`
CommandID string `json:"command_id"`
SenderUserID int64 `json:"sender_user_id"`
SenderUserID int64 `json:"sender_user_id,string"`
RoomID string `json:"room_id"`
RegionID int64 `json:"region_id"`
PacketType string `json:"packet_type"`
@ -85,7 +85,7 @@ type claimDTO struct {
ClaimID string `json:"claim_id"`
CommandID string `json:"command_id"`
PacketID string `json:"packet_id"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
Amount int64 `json:"amount"`
WalletTransactionID string `json:"wallet_transaction_id"`
Status string `json:"status"`

View File

@ -50,7 +50,7 @@ type configDTO struct {
type claimDTO struct {
ClaimID string `json:"claim_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
RewardDay string `json:"reward_day"`
RewardType string `json:"reward_type"`
@ -67,7 +67,7 @@ type claimDTO struct {
}
type userDTO struct {
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
@ -191,24 +191,20 @@ func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64
return 0, false, true
}
appCode := appctx.FromContext(ctx)
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
args...,
)
if err != nil {
return 0, false, false

View File

@ -7,8 +7,10 @@ import (
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
)
const (
@ -177,8 +179,10 @@ func reportWhere(appCode string, query listQuery) (string, []any) {
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
conditions = append(conditions, `(r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)`)
args = append(args, like, like, like, like, like, like, like)
targetSQL, targetArgs := shared.UserIdentityKeywordSQL("u", "r.target_user_id", query.Keyword, time.Now().UnixMilli())
conditions = append(conditions, `(r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR `+targetSQL+`)`)
args = append(args, like, like, like)
args = append(args, targetArgs...)
}
return "WHERE " + strings.Join(conditions, " AND "), args
}

View File

@ -1,6 +1,9 @@
package report
import "testing"
import (
"strings"
"testing"
)
func TestNormalizeListQueryCapsPageSizeAndFilters(t *testing.T) {
query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, Keyword: " abc ", TargetType: "用户", ReportType: " Fraud "})
@ -18,13 +21,26 @@ func TestReportWhereUsesStableFilters(t *testing.T) {
EndAtMS: 200,
}
where, args := reportWhere("lalu", query)
want := "WHERE r.app_code = ? AND r.target_type = ? AND r.report_type = ? AND r.created_at_ms >= ? AND r.created_at_ms < ? AND (r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)"
if where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
for _, want := range []string{
"WHERE r.app_code = ? AND r.target_type = ?",
"r.report_id LIKE ?",
"r.room_id LIKE ?",
"CAST(r.reporter_user_id AS CHAR) LIKE ?",
"CAST(r.target_user_id AS CHAR) LIKE ?",
"u.current_display_user_id LIKE ?",
"u.default_display_user_id LIKE ?",
"pretty_display_user_id_leases lease",
} {
if !strings.Contains(where, want) {
t.Fatalf("where missing %q: %s", want, where)
}
}
if len(args) != 12 || args[0] != "lalu" || args[1] != targetTypeRoom || args[2] != "fraud" || args[3] != int64(100) || args[4] != int64(200) || args[5] != "%10001%" || args[11] != "%10001%" {
if len(args) != 14 || args[0] != "lalu" || args[1] != targetTypeRoom || args[2] != "fraud" || args[3] != int64(100) || args[4] != int64(200) || args[5] != "%10001%" || args[13] != "%10001%" {
t.Fatalf("args mismatch: %#v", args)
}
if _, ok := args[12].(int64); !ok {
t.Fatalf("pretty lease expiry arg type = %T, want int64: %#v", args[12], args)
}
}
func TestParseImageURLsNormalizesEmptyJSON(t *testing.T) {

View File

@ -613,20 +613,20 @@ func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
}
// 资源赠送页允许输入短号,但 wallet 只认内部 user_id这里在 admin 侧先解析,
// 避免前端把短号直接发给 wallet也避免 JS number 对长 user_id 造成精度损失。
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appctx.FromContext(c.Request.Context())}, matchArgs...)
args = append(args, orderArgs...)
row := h.userDB.QueryRowContext(c.Request.Context(), `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ?
AND (CAST(user_id AS CHAR) = ? OR current_display_user_id = ?)
SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appctx.FromContext(c.Request.Context()), keyword, keyword, keyword, keyword,
args...,
)
user := grantUserDTO{}
if err := row.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {

View File

@ -500,7 +500,7 @@ func (r resourceShopItemsRequest) upsertProto(c *gin.Context) *walletv1.UpsertRe
}
func managerGrantEnabledOrDefault(value *bool) *bool {
enabled := true
enabled := false
if value != nil {
enabled = *value
}

View File

@ -52,6 +52,38 @@ func TestGiftRequestProtoClearsCPRelationTypeForNonCPGift(t *testing.T) {
}
}
func TestResourceRequestDefaultsManagerGrantDisabled(t *testing.T) {
req := resourceRequest{
ResourceCode: "badge_default_manager_disabled",
ResourceType: resourceTypeBadge,
Name: "Default Manager Disabled Badge",
Status: "active",
}
proto := req.createProto(newGiftRequestTestContext())
if proto.ManagerGrantEnabled == nil || proto.GetManagerGrantEnabled() {
t.Fatalf("manager grant should default to disabled, got %v", proto.GetManagerGrantEnabled())
}
}
func TestResourceRequestKeepsExplicitManagerGrantEnabled(t *testing.T) {
enabled := true
req := resourceRequest{
ResourceCode: "badge_manager_enabled",
ResourceType: resourceTypeBadge,
Name: "Manager Enabled Badge",
Status: "active",
ManagerGrantEnabled: &enabled,
}
proto := req.createProto(newGiftRequestTestContext())
if proto.ManagerGrantEnabled == nil || !proto.GetManagerGrantEnabled() {
t.Fatalf("explicit manager grant enabled should be preserved, got %v", proto.GetManagerGrantEnabled())
}
}
func newGiftRequestTestContext() *gin.Context {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()

View File

@ -7,6 +7,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@ -114,13 +115,15 @@ func (s *Service) queryOwnerUserIDByDisplayID(ctx context.Context, displayUserID
if displayUserID == "" || s.userDB == nil {
return 0, false, nil
}
matchSQL, matchArgs := shared.UserDisplayIDSQL("u", displayUserID, time.Now().UnixMilli())
args := append([]any{appctx.FromContext(ctx)}, matchArgs...)
var userID int64
err := s.userDB.QueryRowContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ? AND current_display_user_id = ?
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
LIMIT 1
`, appctx.FromContext(ctx), displayUserID).Scan(&userID)
`, args...).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}

View File

@ -52,7 +52,7 @@ type tierDTO struct {
type settlementDTO struct {
SettlementID string `json:"settlement_id"`
RoomID string `json:"room_id"`
OwnerUserID int64 `json:"owner_user_id"`
OwnerUserID int64 `json:"owner_user_id,string"`
PeriodStartMS int64 `json:"period_start_ms"`
PeriodEndMS int64 `json:"period_end_ms"`
CoinSpent int64 `json:"coin_spent"`

View File

@ -49,7 +49,7 @@ type rewardDTO struct {
type claimDTO struct {
ClaimID string `json:"claim_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
User *userDTO `json:"user,omitempty"`
CycleNo int64 `json:"cycle_no"`
CheckinDay string `json:"checkin_day"`
@ -67,7 +67,7 @@ type claimDTO struct {
}
type userDTO struct {
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
@ -167,24 +167,20 @@ func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64
return 0, false, true
}
appCode := appctx.FromContext(ctx)
nowMs := time.Now().UnixMilli()
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
args := append([]any{appCode}, matchArgs...)
args = append(args, orderArgs...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE app_code = ?
AND (
CAST(user_id AS CHAR) = ?
OR current_display_user_id = ?
OR username LIKE ?
)
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
args...,
)
if err != nil {
return 0, false, false

View File

@ -0,0 +1,95 @@
package shared
import (
"fmt"
"strings"
)
func UserIdentityKeywordSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return "1 = 1", nil
}
like := "%" + keyword + "%"
// 后台表格里的用户搜索统一覆盖长 user_id、当前展示号、默认短号、昵称和 active 靓号租约。
// 靓号不能只依赖 users.current_display_user_id历史修复、本地导入或过期恢复可能让 users 快照和租约审计表短暂不一致。
return fmt.Sprintf(`(CAST(%[2]s AS CHAR) LIKE ?
OR %[1]s.current_display_user_id LIKE ?
OR %[1]s.default_display_user_id LIKE ?
OR %[1]s.username LIKE ?
OR %[3]s)`, userAlias, userIDExpr, ActivePrettyDisplayIDSQL(userAlias, "LIKE ?")), []any{
like,
like,
like,
like,
nowMs,
like,
}
}
func UserIdentityExactSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return "1 = 1", nil
}
like := "%" + keyword + "%"
// 单用户解析先做精确 user_id/短号/靓号命中,再允许昵称模糊兜底,避免运营输入靓号时被当成普通昵称查询。
return fmt.Sprintf(`(CAST(%[2]s AS CHAR) = ?
OR %[1]s.current_display_user_id = ?
OR %[1]s.default_display_user_id = ?
OR %[3]s
OR %[1]s.username LIKE ?)`, userAlias, userIDExpr, ActivePrettyDisplayIDSQL(userAlias, "= ?")), []any{
keyword,
keyword,
keyword,
nowMs,
keyword,
like,
}
}
func UserIdentityExactOrderSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) {
keyword = strings.TrimSpace(keyword)
return fmt.Sprintf(`CASE
WHEN CAST(%[2]s AS CHAR) = ? THEN 0
WHEN %[1]s.current_display_user_id = ? THEN 1
WHEN %[1]s.default_display_user_id = ? THEN 2
WHEN %[3]s THEN 3
ELSE 4
END`, userAlias, userIDExpr, ActivePrettyDisplayIDSQL(userAlias, "= ?")), []any{
keyword,
keyword,
keyword,
nowMs,
keyword,
}
}
func UserDisplayIDSQL(userAlias string, displayUserID string, nowMs int64) (string, []any) {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" {
return "1 = 1", nil
}
// 精确短号筛选同样覆盖默认短号、当前展示号和 active 靓号,避免换靓号后只能按其中一种号码查到记录。
return fmt.Sprintf(`(%[1]s.current_display_user_id = ?
OR %[1]s.default_display_user_id = ?
OR %[2]s)`, userAlias, ActivePrettyDisplayIDSQL(userAlias, "= ?")), []any{
displayUserID,
displayUserID,
nowMs,
displayUserID,
}
}
func ActivePrettyDisplayIDSQL(userAlias string, displayPredicate string) string {
return fmt.Sprintf(`EXISTS (
SELECT 1
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = %[1]s.app_code
AND lease.user_id = %[1]s.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
AND lease.display_user_id %[2]s
LIMIT 1
)`, userAlias, displayPredicate)
}

View File

@ -1,5 +1,49 @@
package teamsalarysettlement
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// settlementUserID 只用于批量结算 HTTP 入参,兼容前端新合约的字符串 ID 和旧页面残留的数字 ID。
// 解析后仍以 int64 进入候选过滤和钱包结算,保证结算引擎内部只有一种精确 ID 表示。
type settlementUserID int64
func (v *settlementUserID) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
raw = strings.TrimSpace(text)
if raw == "" {
*v = 0
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid settlement user id %q", raw)
}
*v = settlementUserID(parsed)
return nil
}
func settlementUserIDValues(items []settlementUserID) []int64 {
out := make([]int64, 0, len(items))
for _, item := range items {
out = append(out, int64(item))
}
return out
}
type userDTO struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
@ -81,13 +125,13 @@ type recordQuery struct {
}
type settleRequest struct {
PolicyType string `json:"policy_type"`
TriggerMode string `json:"trigger_mode"`
CycleKey string `json:"cycle_key"`
RegionID int64 `json:"region_id"`
CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code"`
UserIDs []int64 `json:"user_ids"`
PolicyType string `json:"policy_type"`
TriggerMode string `json:"trigger_mode"`
CycleKey string `json:"cycle_key"`
RegionID int64 `json:"region_id"`
CountryID int64 `json:"country_id"`
CountryCode string `json:"country_code"`
UserIDs []settlementUserID `json:"user_ids"`
}
type settleResultDTO struct {

View File

@ -0,0 +1,24 @@
package teamsalarysettlement
import (
"encoding/json"
"testing"
)
func TestSettlementUserIDsAcceptStringAndNumber(t *testing.T) {
t.Parallel()
var req settleRequest
if err := json.Unmarshal([]byte(`{"user_ids":["323096761415507968",323096761415507969]}`), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
got := settlementUserIDValues(req.UserIDs)
want := []int64{323096761415507968, 323096761415507969}
if len(got) != len(want) {
t.Fatalf("user ids length = %d, want %d", len(got), len(want))
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("user ids[%d] = %d, want %d", index, got[index], want[index])
}
}
}

View File

@ -223,18 +223,19 @@ func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID in
if req.CycleKey == "" {
req.CycleKey = previousMonthCycleKey(time.Now().UTC())
}
userIDs := settlementUserIDValues(req.UserIDs)
userFilter := int64(0)
if len(req.UserIDs) == 1 {
userFilter = req.UserIDs[0]
if len(userIDs) == 1 {
userFilter = userIDs[0]
}
// 单用户结算在候选聚合阶段下推 userFilter批量结算先算区域候选再做白名单过滤。
candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, userFilter, time.Now().UTC().UnixMilli())
if err != nil {
return settleResultDTO{}, err
}
if len(req.UserIDs) > 0 {
if len(userIDs) > 0 {
allowed := map[int64]struct{}{}
for _, id := range req.UserIDs {
for _, id := range userIDs {
if id > 0 {
allowed[id] = struct{}{}
}
@ -250,7 +251,7 @@ func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID in
}
sortTeamCandidates(candidates)
result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(req.UserIDs), Records: []recordDTO{}}
result := settleResultDTO{PolicyType: req.PolicyType, CycleKey: req.CycleKey, RequestedCount: len(userIDs), Records: []recordDTO{}}
profiles, _ := s.userProfiles(ctx, appCode, candidateUserIDs(candidates))
for _, candidate := range candidates {
result.ProcessedCount++

View File

@ -73,7 +73,7 @@ type rewardDTO struct {
type leaderboardEntryDTO struct {
RankNo int32 `json:"rank_no"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
Score int64 `json:"score"`
FirstScoredAtMS int64 `json:"first_scored_at_ms"`
LastScoredAtMS int64 `json:"last_scored_at_ms"`
@ -86,7 +86,7 @@ type settlementDTO struct {
SettlementID string `json:"settlement_id"`
CycleID string `json:"cycle_id"`
RankNo int32 `json:"rank_no"`
UserID int64 `json:"user_id"`
UserID int64 `json:"user_id,string"`
Score int64 `json:"score"`
ResourceGroupID int64 `json:"resource_group_id"`
WalletCommandID string `json:"wallet_command_id"`

View File

@ -0,0 +1,22 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- 线上生产环境关闭 bootstrap 和 AutoMigrate新按钮权限必须通过 migration 落库,
-- 否则前端即使已经上线,也会因为登录权限列表缺少该 code 而隐藏“工资兑换比例”按钮。
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('币商工资兑换比例', 'coin-seller:exchange-rate', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
-- 平台管理员应拥有全部后台能力;运维管理员也需要维护币商工资转金币比例。
-- INSERT IGNORE 保持幂等,重复执行不会产生重复绑定。
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
AND admin_permission.code = 'coin-seller:exchange-rate';

View File

@ -157,6 +157,34 @@ type ClaimQuery struct {
PageSize int32
}
// Summary 是后台邀请活动列表的一行,按邀请人和周期聚合邀请人数、有效人数、充值和奖励。
type Summary struct {
AppCode string
CycleKey string
UserID int64
InviteCount int64
ValidInviteCount int64
TotalRechargeCoinAmount int64
TotalRewardCoinAmount int64
UpdatedAtMS int64
Invitees []InviteeSummary
}
// InviteeSummary 是后台聚合行展开后的被邀请人明细,按被邀请人聚合充值并保留首次邀请时间。
type InviteeSummary struct {
InvitedUserID int64
RechargeCoinAmount int64
InvitedAtMS int64
}
// SummaryQuery 是后台邀请活动聚合列表查询条件UserID 只过滤邀请人CycleKey 为空表示所有周期。
type SummaryQuery struct {
UserID int64
CycleKey string
Page int32
PageSize int32
}
// LeaderboardEntry 是邀请活动月榜的一行,只统计已经达到有效邀请标准的邀请人数。
type LeaderboardEntry struct {
RankNo int32

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