From 9df03674d1d08773946f3dbbad52297d6eba8d0d Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 12:08:01 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/admin/cmd/server/main.go | 2 +- .../cumulativerechargereward/handler.go | 4 +- .../internal/modules/dashboard/handler.go | 7 +- .../internal/modules/dashboard/service.go | 93 ++++++++- .../modules/firstrechargereward/handler.go | 4 +- .../admin/internal/modules/hostorg/request.go | 76 +++++-- .../internal/modules/hostorg/request_test.go | 31 +++ .../admin/internal/modules/hostorg/service.go | 20 +- .../modules/inviteactivityreward/handler.go | 4 +- server/admin/internal/modules/payment/dto.go | 4 +- .../internal/modules/redpacket/handler.go | 4 +- .../modules/registrationreward/handler.go | 4 +- .../modules/roomturnoverreward/handler.go | 2 +- .../modules/sevendaycheckin/handler.go | 4 +- .../modules/teamsalarysettlement/dto.go | 58 +++++- .../modules/teamsalarysettlement/dto_test.go | 24 +++ .../modules/teamsalarysettlement/service.go | 11 +- .../internal/modules/weeklystar/handler.go | 4 +- services/game-service/internal/app/app.go | 2 +- .../internal/client/wallet_client.go | 4 + .../internal/service/roomrps/service.go | 190 +++++++++++++++++- .../internal/service/roomrps/service_test.go | 123 ++++++++++++ .../internal/storage/mysql/query.go | 61 ++++-- .../internal/storage/mysql/query_test.go | 107 ++++++++++ 24 files changed, 752 insertions(+), 91 deletions(-) create mode 100644 server/admin/internal/modules/hostorg/request_test.go create mode 100644 server/admin/internal/modules/teamsalarysettlement/dto_test.go create mode 100644 services/game-service/internal/service/roomrps/service_test.go diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index d0c4d314..f1628b88 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -252,7 +252,7 @@ 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), diff --git a/server/admin/internal/modules/cumulativerechargereward/handler.go b/server/admin/internal/modules/cumulativerechargereward/handler.go index 165af9c4..8b4aa4d4 100644 --- a/server/admin/internal/modules/cumulativerechargereward/handler.go +++ b/server/admin/internal/modules/cumulativerechargereward/handler.go @@ -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"` diff --git a/server/admin/internal/modules/dashboard/handler.go b/server/admin/internal/modules/dashboard/handler.go index 6c3a36e6..e08bf91c 100644 --- a/server/admin/internal/modules/dashboard/handler.go +++ b/server/admin/internal/modules/dashboard/handler.go @@ -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) { @@ -46,6 +48,7 @@ func (h *Handler) StatisticsOverview(c *gin.Context) { 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")), diff --git a/server/admin/internal/modules/dashboard/service.go b/server/admin/internal/modules/dashboard/service.go index f58dc8ba..5b680325 100644 --- a/server/admin/internal/modules/dashboard/service.go +++ b/server/admin/internal/modules/dashboard/service.go @@ -10,6 +10,7 @@ import ( "strings" "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/repository" ) @@ -17,6 +18,7 @@ type DashboardService struct { store *repository.Store cfg config.Config httpClient *http.Client + user userclient.Client } type StatisticsQuery struct { @@ -31,6 +33,7 @@ type StatisticsQuery struct { type SelfGameStatisticsQuery struct { AppCode string + RequestID string StartMS int64 EndMS int64 RegionID int64 @@ -38,11 +41,12 @@ type SelfGameStatisticsQuery struct { GameID string } -func NewService(store *repository.Store, cfg config.Config) *DashboardService { +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, } } @@ -93,7 +97,13 @@ func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query values.Set("game_id", gameID) } // 数据大屏只通过 statistics-service 读取自研游戏聚合指标,避免 admin 服务绕过聚合层去扫业务明细表。 - return s.statisticsGET(ctx, "/internal/v1/statistics/self-games/overview", values) + 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) { @@ -111,8 +121,85 @@ func (s *DashboardService) statisticsGET(ctx context.Context, path string, value 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 位以上业务 ID,admin 层统一改成字符串透给 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 + } +} diff --git a/server/admin/internal/modules/firstrechargereward/handler.go b/server/admin/internal/modules/firstrechargereward/handler.go index 160495c3..8416b1bf 100644 --- a/server/admin/internal/modules/firstrechargereward/handler.go +++ b/server/admin/internal/modules/firstrechargereward/handler.go @@ -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"` diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index 186bdc23..f2d599b3 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -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 { @@ -79,12 +119,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 { diff --git a/server/admin/internal/modules/hostorg/request_test.go b/server/admin/internal/modules/hostorg/request_test.go new file mode 100644 index 00000000..f877bbd1 --- /dev/null +++ b/server/admin/internal/modules/hostorg/request_test.go @@ -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) + } + }) + } +} diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index 5722637a..31d7ebe7 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -118,7 +118,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 +167,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 +214,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 } @@ -334,17 +334,17 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell } 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 } diff --git a/server/admin/internal/modules/inviteactivityreward/handler.go b/server/admin/internal/modules/inviteactivityreward/handler.go index ea66b7de..19c60775 100644 --- a/server/admin/internal/modules/inviteactivityreward/handler.go +++ b/server/admin/internal/modules/inviteactivityreward/handler.go @@ -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"` @@ -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"` diff --git a/server/admin/internal/modules/payment/dto.go b/server/admin/internal/modules/payment/dto.go index 11b69891..e9e029fd 100644 --- a/server/admin/internal/modules/payment/dto.go +++ b/server/admin/internal/modules/payment/dto.go @@ -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"` diff --git a/server/admin/internal/modules/redpacket/handler.go b/server/admin/internal/modules/redpacket/handler.go index c67a966c..8d83ce99 100644 --- a/server/admin/internal/modules/redpacket/handler.go +++ b/server/admin/internal/modules/redpacket/handler.go @@ -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"` diff --git a/server/admin/internal/modules/registrationreward/handler.go b/server/admin/internal/modules/registrationreward/handler.go index 474c992a..d08a4b83 100644 --- a/server/admin/internal/modules/registrationreward/handler.go +++ b/server/admin/internal/modules/registrationreward/handler.go @@ -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"` diff --git a/server/admin/internal/modules/roomturnoverreward/handler.go b/server/admin/internal/modules/roomturnoverreward/handler.go index 627dcff8..fce97d1b 100644 --- a/server/admin/internal/modules/roomturnoverreward/handler.go +++ b/server/admin/internal/modules/roomturnoverreward/handler.go @@ -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"` diff --git a/server/admin/internal/modules/sevendaycheckin/handler.go b/server/admin/internal/modules/sevendaycheckin/handler.go index 2827f44d..f46d6b84 100644 --- a/server/admin/internal/modules/sevendaycheckin/handler.go +++ b/server/admin/internal/modules/sevendaycheckin/handler.go @@ -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"` diff --git a/server/admin/internal/modules/teamsalarysettlement/dto.go b/server/admin/internal/modules/teamsalarysettlement/dto.go index 62319abc..f6c7a0de 100644 --- a/server/admin/internal/modules/teamsalarysettlement/dto.go +++ b/server/admin/internal/modules/teamsalarysettlement/dto.go @@ -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 { diff --git a/server/admin/internal/modules/teamsalarysettlement/dto_test.go b/server/admin/internal/modules/teamsalarysettlement/dto_test.go new file mode 100644 index 00000000..3e750df4 --- /dev/null +++ b/server/admin/internal/modules/teamsalarysettlement/dto_test.go @@ -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]) + } + } +} diff --git a/server/admin/internal/modules/teamsalarysettlement/service.go b/server/admin/internal/modules/teamsalarysettlement/service.go index adec83f8..48f2ada1 100644 --- a/server/admin/internal/modules/teamsalarysettlement/service.go +++ b/server/admin/internal/modules/teamsalarysettlement/service.go @@ -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++ diff --git a/server/admin/internal/modules/weeklystar/handler.go b/server/admin/internal/modules/weeklystar/handler.go index ae8ce5ae..90673981 100644 --- a/server/admin/internal/modules/weeklystar/handler.go +++ b/server/admin/internal/modules/weeklystar/handler.go @@ -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"` diff --git a/services/game-service/internal/app/app.go b/services/game-service/internal/app/app.go index c41d6ba9..1aa8e5f3 100644 --- a/services/game-service/internal/app/app.go +++ b/services/game-service/internal/app/app.go @@ -115,7 +115,7 @@ func New(cfg config.Config) (*App, error) { logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false")) } // 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。 - roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher) + roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient) transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc) gamev1.RegisterGameAppServiceServer(server, transport) gamev1.RegisterGameCallbackServiceServer(server, transport) diff --git a/services/game-service/internal/client/wallet_client.go b/services/game-service/internal/client/wallet_client.go index 2fcde252..c5bf6f35 100644 --- a/services/game-service/internal/client/wallet_client.go +++ b/services/game-service/internal/client/wallet_client.go @@ -22,3 +22,7 @@ func (c *WalletClient) ApplyGameCoinChange(ctx context.Context, req *walletv1.Ap func (c *WalletClient) GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { return c.client.GetBalances(ctx, req) } + +func (c *WalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { + return c.client.ListGiftConfigs(ctx, req) +} diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 43d8c75b..5adf0bce 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -15,6 +15,7 @@ import ( "google.golang.org/protobuf/proto" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" @@ -56,6 +57,11 @@ type UserClient interface { GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) } +// GiftCatalogClient 只暴露 room-rps 配置补齐需要的礼物配置查询能力;礼物名称、图标和币价仍由 wallet-service 作为 owner 输出。 +type GiftCatalogClient interface { + ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) +} + // Publisher 是房间 IM 投递边界;生产用腾讯 IM REST,测试可以注入 fake,service 不感知具体 SDK。 type Publisher interface { EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error @@ -70,6 +76,7 @@ type Config struct { type Service struct { config Config user UserClient + gifts GiftCatalogClient pub Publisher mu sync.Mutex @@ -80,7 +87,7 @@ type Service struct { // New 创建房内猜拳用例层。 // 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。 -func New(config Config, user UserClient, publisher Publisher) *Service { +func New(config Config, user UserClient, publisher Publisher, giftCatalog ...GiftCatalogClient) *Service { if config.ChallengeTimeout <= 0 { // 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。 config.ChallengeTimeout = 10 * time.Minute @@ -89,9 +96,15 @@ func New(config Config, user UserClient, publisher Publisher) *Service { // 揭晓动画固定 3 秒;IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。 config.RevealCountdown = 3 * time.Second } + var gifts GiftCatalogClient + if len(giftCatalog) > 0 { + // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 + gifts = giftCatalog[0] + } return &Service{ config: config, user: user, + gifts: gifts, pub: publisher, configs: make(map[string]*gamev1.RoomRPSConfig), challenges: make(map[string]*gamev1.RoomRPSChallenge), @@ -113,10 +126,24 @@ func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSCon ctx = appcode.WithContext(ctx, app) s.mu.Lock() - defer s.mu.Unlock() - config := s.configForAppLocked(app) - return cloneConfig(config), s.now().UnixMilli(), nil + cloned := cloneConfig(config) + originalUpdatedAtMS := config.GetUpdatedAtMs() + s.mu.Unlock() + + enriched, changed, err := s.enrichConfigGifts(ctx, app, cloned) + if err != nil { + return nil, 0, err + } + if changed { + s.mu.Lock() + // 线上已有旧配置可能只保存 gift_id;读接口补齐成功后同步回内存,后续发起挑战也能使用真实币价和展示快照。 + if current := s.configs[app]; current != nil && current.GetUpdatedAtMs() == originalUpdatedAtMS { + s.configs[app] = cloneConfig(enriched) + } + s.mu.Unlock() + } + return enriched, s.now().UnixMilli(), nil } func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) { @@ -126,7 +153,7 @@ func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.R app = appcode.Normalize(app) ctx = appcode.WithContext(ctx, app) - normalized, err := s.normalizeConfig(app, config) + normalized, err := s.normalizeConfig(ctx, app, config) if err != nil { return nil, 0, err } @@ -432,7 +459,7 @@ func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig { return config } -func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) { +func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) { if config == nil { return nil, xerr.New(xerr.InvalidArgument, "room rps config is required") } @@ -485,7 +512,13 @@ func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*ga Enabled: gift.GetEnabled(), SortOrder: sortOrder, } - if fallback := defaultsByID[gift.GetGiftId()]; fallback != nil { + gifts = append(gifts, normalized) + } + if _, err := s.enrichStakeGifts(ctx, app, gifts); err != nil { + return nil, err + } + for _, normalized := range gifts { + if fallback := defaultsByID[normalized.GetGiftId()]; fallback != nil { if normalized.GiftName == "" { normalized.GiftName = fallback.GetGiftName() } @@ -498,9 +531,8 @@ func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*ga } if normalized.GiftPriceCoin <= 0 { // 运行时必须知道当前档位折算金币,后续接钱包托管也以这个金额作为订单事实。 - normalized.GiftPriceCoin = int64(sortOrder) * 100 + normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100 } - gifts = append(gifts, normalized) } sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() }) return &gamev1.RoomRPSConfig{ @@ -513,6 +545,146 @@ func (s *Service) normalizeConfig(app string, config *gamev1.RoomRPSConfig) (*ga }, nil } +func (s *Service) enrichConfigGifts(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, bool, error) { + if config == nil { + return nil, false, nil + } + changed, err := s.enrichStakeGifts(ctx, app, config.GetStakeGifts()) + if err != nil { + return nil, false, err + } + return config, changed, nil +} + +func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gamev1.RoomRPSStakeGift) (bool, error) { + if len(gifts) == 0 { + return false, nil + } + needGiftIDs := make([]int64, 0, len(gifts)) + seen := make(map[int64]struct{}, len(gifts)) + for _, gift := range gifts { + if gift == nil || gift.GetGiftId() <= 0 { + continue + } + if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftPriceCoin() > 0 { + continue + } + if _, ok := seen[gift.GetGiftId()]; ok { + continue + } + // 只查询缺字段的档位,避免每次读配置都把 wallet-service 当成全量礼物列表使用。 + seen[gift.GetGiftId()] = struct{}{} + needGiftIDs = append(needGiftIDs, gift.GetGiftId()) + } + if len(needGiftIDs) == 0 { + return false, nil + } + catalog, err := s.roomRPSGiftCatalog(ctx, app, needGiftIDs) + if err != nil { + return false, err + } + changed := false + for _, gift := range gifts { + if gift == nil { + continue + } + item := catalog[gift.GetGiftId()] + if item == nil { + continue + } + if gift.GiftName == "" { + if name := roomRPSGiftDisplayName(item); name != "" { + // 后台保存 room-rps 时只提交 gift_id;运行时展示名必须来自 wallet-service 礼物配置,不能让前端猜。 + gift.GiftName = name + changed = true + } + } + if gift.GiftIconUrl == "" { + if iconURL := roomRPSGiftIconURL(item); iconURL != "" { + // Flutter 的 PK 面板只消费 gift_icon_url;这里把礼物资源主图折叠成该字段,保持接口契约稳定。 + gift.GiftIconUrl = iconURL + changed = true + } + } + if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 { + // 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。 + gift.GiftPriceCoin = item.GetCoinPrice() + changed = true + } + } + return changed, nil +} + +func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []int64) (map[int64]*walletv1.GiftConfig, error) { + result := make(map[int64]*walletv1.GiftConfig, len(giftIDs)) + if s.gifts == nil { + return result, nil + } + app = appcode.Normalize(app) + for _, numericGiftID := range giftIDs { + if numericGiftID <= 0 { + continue + } + giftID := strconv.FormatInt(numericGiftID, 10) + for page := int32(1); ; page++ { + resp, err := s.gifts.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{ + RequestId: fmt.Sprintf("room-rps-config:%s:%s:%d", app, giftID, page), + AppCode: app, + Keyword: giftID, + Page: page, + PageSize: 100, + }) + if err != nil { + return nil, xerr.New(xerr.Unavailable, "room rps gift config lookup failed") + } + for _, item := range resp.GetGifts() { + if strings.TrimSpace(item.GetGiftId()) != giftID { + continue + } + result[numericGiftID] = item + break + } + if result[numericGiftID] != nil { + break + } + if len(resp.GetGifts()) < 100 { + break + } + if total := resp.GetTotal(); total > 0 && int64(page)*100 >= total { + break + } + } + } + return result, nil +} + +func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { + if gift == nil { + return "" + } + if name := strings.TrimSpace(gift.GetName()); name != "" { + return name + } + return strings.TrimSpace(gift.GetResource().GetName()) +} + +func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string { + if gift == nil || gift.GetResource() == nil { + return "" + } + candidates := []string{ + gift.GetResource().GetAssetUrl(), + gift.GetResource().GetPreviewUrl(), + gift.GetResource().GetAnimationUrl(), + } + for _, candidate := range candidates { + if value := strings.TrimSpace(candidate); value != "" { + return value + } + } + return "" +} + func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error { if s.pub == nil || challenge == nil { return nil diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go new file mode 100644 index 00000000..bebb093b --- /dev/null +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -0,0 +1,123 @@ +package roomrps + +import ( + "context" + "testing" + "time" + + gamev1 "hyapp.local/api/proto/game/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" +) + +func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) { + catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ + "2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "", 100), + "2002": roomRPSWalletGift("2002", "Bell", "", "https://cdn.example.test/bell-preview.png", "", 300), + "2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500), + "2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000), + }) + svc := New(Config{}, nil, nil, catalog) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + config, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftId: 2001, Enabled: true, SortOrder: 1}, + {GiftId: 2002, Enabled: true, SortOrder: 2}, + {GiftId: 2003, Enabled: true, SortOrder: 3}, + {GiftId: 2004, Enabled: true, SortOrder: 4}, + }, + }) + if err != nil { + t.Fatalf("UpdateConfig() error = %v", err) + } + + if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rose" || got.GetGiftIconUrl() != "https://cdn.example.test/rose.png" || got.GetGiftPriceCoin() != 100 { + t.Fatalf("first gift was not enriched from wallet catalog: %+v", got) + } + if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" { + t.Fatalf("second gift should use preview_url when asset_url is empty: %+v", got) + } + if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "https://cdn.example.test/crown.svga" { + t.Fatalf("third gift should use animation_url as last display fallback: %+v", got) + } + if len(catalog.requests) != 4 { + t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests)) + } + if req := catalog.requests[0]; req.GetAppCode() != "lalu" || req.GetKeyword() != "2001" || req.GetPage() != 1 || req.GetPageSize() != 100 { + t.Fatalf("catalog lookup request mismatch: %+v", req) + } +} + +func TestGetConfigBackfillsLegacyEmptyGiftSnapshot(t *testing.T) { + catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ + "3001": roomRPSWalletGift("3001", "Rocket", "https://cdn.example.test/rocket.png", "", "", 900), + }) + svc := New(Config{}, nil, nil, catalog) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + svc.configs["lalu"] = &gamev1.RoomRPSConfig{ + AppCode: "lalu", + GameId: GameID, + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftId: 3001, Enabled: true, SortOrder: 1}, + {GiftId: 10002, Enabled: true, SortOrder: 2}, + {GiftId: 10003, Enabled: true, SortOrder: 3}, + {GiftId: 10004, Enabled: true, SortOrder: 4}, + }, + CreatedAtMs: 1000, + UpdatedAtMs: 2000, + } + + config, _, err := svc.GetConfig(context.Background(), "lalu") + if err != nil { + t.Fatalf("GetConfig() error = %v", err) + } + + if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rocket" || got.GetGiftIconUrl() != "https://cdn.example.test/rocket.png" || got.GetGiftPriceCoin() != 900 { + t.Fatalf("legacy config response was not enriched: %+v", got) + } + if cached := svc.configs["lalu"].GetStakeGifts()[0]; cached.GetGiftIconUrl() == "" { + t.Fatalf("legacy config was not backfilled into service cache: %+v", cached) + } +} + +type fakeGiftCatalog struct { + gifts map[string]*walletv1.GiftConfig + requests []*walletv1.ListGiftConfigsRequest +} + +func newFakeGiftCatalog(gifts map[string]*walletv1.GiftConfig) *fakeGiftCatalog { + return &fakeGiftCatalog{gifts: gifts} +} + +func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { + f.requests = append(f.requests, req) + if gift := f.gifts[req.GetKeyword()]; gift != nil { + return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{gift}, Total: 1}, nil + } + return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil +} + +func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig { + return &walletv1.GiftConfig{ + AppCode: "lalu", + GiftId: giftID, + Name: name, + Status: "active", + CoinPrice: coinPrice, + Resource: &walletv1.Resource{ + ResourceId: 100, + ResourceType: "gift", + Name: name, + Status: "active", + AssetUrl: assetURL, + PreviewUrl: previewURL, + AnimationUrl: animationURL, + }, + } +} diff --git a/services/statistics-service/internal/storage/mysql/query.go b/services/statistics-service/internal/storage/mysql/query.go index dd3dd21f..b29cfa92 100644 --- a/services/statistics-service/internal/storage/mysql/query.go +++ b/services/statistics-service/internal/storage/mysql/query.go @@ -5,6 +5,7 @@ import ( "database/sql" "math" "sort" + "strconv" "time" "hyapp/pkg/appcode" @@ -978,7 +979,9 @@ func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, start if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil { return nil, err } - item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin+item.RobotStakeCoin) + // platform_profit_coin 是 game-service 在结算时按真实用户侧快照写入的 user_stake - payout - refund; + // 机器人下注只是奖池和对手侧流水,不能进入抽水率分母,否则人机局会把平台抽水率稀释一半。 + item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin) out = append(out, item) } return out, rows.Err() @@ -991,13 +994,29 @@ func (r *Repository) querySelfGamePools(ctx context.Context, app, startDay, endD filter += " AND game_id = ?" args = append(args, gameID) } + latestArgs := append([]any{}, args...) + latestArgs = append(latestArgs, app) + latestArgs = append(latestArgs, args...) rows, err := r.db.QueryContext(ctx, ` - SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0), - COALESCE(SUM(in_coin),0), COALESCE(SUM(out_coin),0), COALESCE(MAX(balance_after_coin),0) - FROM stat_self_game_pool_day - WHERE `+filter+` - GROUP BY game_id, direction, reason - ORDER BY game_id, direction, reason`, args...) + SELECT agg.game_id, agg.direction, agg.reason, agg.transaction_count, + agg.in_coin, agg.out_coin, COALESCE(latest.balance_after_coin,0) + FROM ( + SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0) AS transaction_count, + COALESCE(SUM(in_coin),0) AS in_coin, COALESCE(SUM(out_coin),0) AS out_coin + FROM stat_self_game_pool_day + WHERE `+filter+` + GROUP BY game_id, direction, reason + ) agg + LEFT JOIN stat_self_game_pool_day latest ON latest.app_code = ? AND latest.game_id = agg.game_id + AND latest.direction = agg.direction AND latest.reason = agg.reason + AND latest.stat_day = ( + SELECT p2.stat_day + FROM stat_self_game_pool_day p2 + WHERE `+filter+` AND p2.game_id = agg.game_id AND p2.direction = agg.direction AND p2.reason = agg.reason + ORDER BY p2.stat_day DESC, p2.updated_at_ms DESC + LIMIT 1 + ) + ORDER BY agg.game_id, agg.direction, agg.reason`, latestArgs...) if err != nil { return nil, err } @@ -1033,10 +1052,7 @@ func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, a if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil { return nil, err } - key := item.GameID + "\x00" + item.ParticipantType + "\x00" + item.RPSGesture - if item.DicePoint > 0 { - key = item.GameID + "\x00" + item.ParticipantType + "\x00" + string(rune(item.DicePoint)) - } + key := selfGameParticipantRateKey(item) totals[key] += item.ParticipantCount items = append(items, item) } @@ -1044,10 +1060,7 @@ func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, a return nil, err } for index := range items { - key := items[index].GameID + "\x00" + items[index].ParticipantType + "\x00" + items[index].RPSGesture - if items[index].DicePoint > 0 { - key = items[index].GameID + "\x00" + items[index].ParticipantType + "\x00" + string(rune(items[index].DicePoint)) - } + key := selfGameParticipantRateKey(items[index]) if items[index].Result == "win" { items[index].WinRate = ratio(items[index].ParticipantCount, totals[key]) } @@ -1055,6 +1068,16 @@ func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, a return items, nil } +func selfGameParticipantRateKey(item SelfGameParticipantStat) string { + playKey := item.RPSGesture + if item.DicePoint > 0 { + playKey = strconv.FormatInt(item.DicePoint, 10) + } + // 玩法分布查询本身按 stake_coin 分桶返回,胜率分母也必须保留同一档位; + // 否则同一点数或同一手势在不同下注档位的结果会互相稀释,前端每一行的胜率就不是该行口径。 + return item.GameID + "\x00" + strconv.FormatInt(item.StakeCoin, 10) + "\x00" + item.ParticipantType + "\x00" + playKey +} + func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) { filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID) rows, err := r.db.QueryContext(ctx, ` @@ -1167,7 +1190,9 @@ func applySelfGameMatchDerived(totals *SelfGameMatchTotals) { totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches) totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches) totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches) - totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin+totals.RobotStakeCoin) + // platform_profit_coin 与真实用户下注同口径,分母只使用 user_stake_coin; + // robot_stake_coin 是机器人对手侧流水,展示总下注时可以合并,但不能用于真实用户侧抽水率。 + totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin) } func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) { @@ -1269,7 +1294,7 @@ func selfGameMetricDefinitions() []MetricDefinition { {Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户;只统计 stat_self_game_user_day.match_count > 0。"}, {Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"}, {Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局;game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"}, - {Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin + robot_stake_coin - payout_coin - refund_coin;与奖池入池/出池流水分表展示。"}, - {Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合;in_coin/out_coin 是入池/出池金额,balance_after_coin 是当天同方向同原因聚合行内最新余额快照。"}, + {Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin - payout_coin - refund_coin;抽水率分母同样只用 user_stake_coin。"}, + {Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合;in_coin/out_coin 是入池/出池金额,balance_after_coin 是查询窗口内同方向同原因的最新余额快照。"}, } } diff --git a/services/statistics-service/internal/storage/mysql/query_test.go b/services/statistics-service/internal/storage/mysql/query_test.go index 72943502..c1568866 100644 --- a/services/statistics-service/internal/storage/mysql/query_test.go +++ b/services/statistics-service/internal/storage/mysql/query_test.go @@ -147,3 +147,110 @@ func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *te t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0]) } } + +func TestQuerySelfGameOverviewUsesRealUserStakeForPlatformProfitRate(t *testing.T) { + ctx := context.Background() + _, file, _, _ := runtime.Caller(0) + statsSchema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"), + DatabasePrefix: "hy_stats_self_game_profit_test", + }) + repository, err := Open(ctx, statsSchema.DSN) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repository.Close() }) + + if _, err := repository.db.ExecContext(ctx, ` + INSERT INTO stat_self_game_match_day ( + app_code, stat_day, game_id, stake_coin, settled_matches, + user_stake_coin, robot_stake_coin, payout_coin, refund_coin, platform_profit_coin, updated_at_ms + ) VALUES + ('lalu', '2026-06-06', 'dice', 100, 1, 1000, 1000, 700, 100, 200, 1), + ('lalu', '2026-06-06', 'dice', 500, 1, 2000, 2000, 1500, 100, 400, 1)`); err != nil { + t.Fatalf("seed self game match stats: %v", err) + } + + start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli() + overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"}) + if err != nil { + t.Fatalf("query self game overview: %v", err) + } + if overview.MatchTotals.PlatformProfitRate != 0.2 { + t.Fatalf("platform profit rate should use real user stake only: %+v", overview.MatchTotals) + } + if len(overview.StakeBreakdown) != 2 || overview.StakeBreakdown[0].PlatformProfitRate != 0.2 || overview.StakeBreakdown[1].PlatformProfitRate != 0.2 { + t.Fatalf("stake platform profit rate should use real user stake only: %+v", overview.StakeBreakdown) + } +} + +func TestQuerySelfGameOverviewKeepsParticipantWinRateInStakeBucket(t *testing.T) { + ctx := context.Background() + _, file, _, _ := runtime.Caller(0) + statsSchema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"), + DatabasePrefix: "hy_stats_self_game_participant_test", + }) + repository, err := Open(ctx, statsSchema.DSN) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repository.Close() }) + + if _, err := repository.db.ExecContext(ctx, ` + INSERT INTO stat_self_game_participant_day ( + app_code, stat_day, game_id, stake_coin, participant_type, result, dice_point, participant_count, updated_at_ms + ) VALUES + ('lalu', '2026-06-06', 'dice', 100, 'user', 'win', 6, 1, 1), + ('lalu', '2026-06-06', 'dice', 100, 'user', 'lose', 6, 3, 1), + ('lalu', '2026-06-06', 'dice', 500, 'user', 'win', 6, 4, 1)`); err != nil { + t.Fatalf("seed self game participant stats: %v", err) + } + + start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli() + overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), GameID: "dice"}) + if err != nil { + t.Fatalf("query self game overview: %v", err) + } + rates := map[int64]float64{} + for _, item := range overview.ParticipantDistribution { + if item.Result == "win" { + rates[item.StakeCoin] = item.WinRate + } + } + if rates[100] != 0.25 || rates[500] != 1 { + t.Fatalf("participant win rate should stay in stake bucket: %+v", overview.ParticipantDistribution) + } +} + +func TestQuerySelfGameOverviewUsesLatestPoolBalanceSnapshot(t *testing.T) { + ctx := context.Background() + _, file, _, _ := runtime.Caller(0) + statsSchema := mysqlschema.New(t, mysqlschema.Config{ + InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"), + DatabasePrefix: "hy_stats_self_game_pool_test", + }) + repository, err := Open(ctx, statsSchema.DSN) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repository.Close() }) + + if _, err := repository.db.ExecContext(ctx, ` + INSERT INTO stat_self_game_pool_day ( + app_code, stat_day, game_id, direction, reason, transaction_count, in_coin, out_coin, balance_after_coin, updated_at_ms + ) VALUES + ('lalu', '2026-06-05', 'dice', 'out', 'dice_settlement', 1, 0, 100, 5000, 1), + ('lalu', '2026-06-06', 'dice', 'out', 'dice_settlement', 1, 0, 200, 4800, 2)`); err != nil { + t.Fatalf("seed self game pool stats: %v", err) + } + + start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli() + overview, err := repository.QuerySelfGameOverview(ctx, SelfGameOverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), GameID: "dice"}) + if err != nil { + t.Fatalf("query self game overview: %v", err) + } + if len(overview.PoolStats) != 1 || overview.PoolStats[0].OutCoin != 300 || overview.PoolStats[0].BalanceAfterCoin != 4800 { + t.Fatalf("pool balance should be latest snapshot, not max balance: %+v", overview.PoolStats) + } +}