diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index e3e50e10..ab3a1793 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -51,6 +51,7 @@ import ( searchmodule "hyapp-admin-server/internal/modules/search" sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin" uploadmodule "hyapp-admin-server/internal/modules/upload" + userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard" "hyapp-admin-server/internal/platform/logging" "hyapp-admin-server/internal/platform/tencentcos" "hyapp-admin-server/internal/repository" @@ -237,6 +238,7 @@ func main() { Search: searchmodule.New(store), SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), + UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomDB), } engine := router.New(cfg, auth, handlers) diff --git a/server/admin/internal/modules/gamemanagement/dto.go b/server/admin/internal/modules/gamemanagement/dto.go index f940f4a8..2e4f2315 100644 --- a/server/admin/internal/modules/gamemanagement/dto.go +++ b/server/admin/internal/modules/gamemanagement/dto.go @@ -3,18 +3,21 @@ package gamemanagement import gamev1 "hyapp.local/api/proto/game/v1" type platformDTO struct { - AppCode string `json:"appCode"` - PlatformCode string `json:"platformCode"` - PlatformName string `json:"platformName"` - Status string `json:"status"` - APIBaseURL string `json:"apiBaseUrl"` - AdapterType string `json:"adapterType"` + AppCode string `json:"appCode"` + PlatformCode string `json:"platformCode"` + PlatformName string `json:"platformName"` + Status string `json:"status"` + APIBaseURL string `json:"apiBaseUrl"` + // adapterType 决定服务端按哪家厂商协议拼启动 URL 和处理回调。 + AdapterType string `json:"adapterType"` + // callbackSecretSet 只告诉后台“已配置”,不把密钥明文下发到浏览器。 CallbackSecretSet bool `json:"callbackSecretSet"` CallbackIPWhitelist []string `json:"callbackIpWhitelist"` - AdapterConfigJSON string `json:"adapterConfigJson"` - SortOrder int32 `json:"sortOrder"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` + // adapterConfigJson 承载 uid_mode/default_lang/token_ttl_seconds 等可热更新配置。 + AdapterConfigJSON string `json:"adapterConfigJson"` + SortOrder int32 `json:"sortOrder"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` } type catalogDTO struct { diff --git a/server/admin/internal/modules/gamemanagement/request.go b/server/admin/internal/modules/gamemanagement/request.go index 47d61232..9bf11103 100644 --- a/server/admin/internal/modules/gamemanagement/request.go +++ b/server/admin/internal/modules/gamemanagement/request.go @@ -12,15 +12,18 @@ import ( ) type platformRequest struct { - PlatformCode string `json:"platformCode"` - PlatformName string `json:"platformName"` - Status string `json:"status"` - APIBaseURL string `json:"apiBaseUrl"` - AdapterType string `json:"adapterType"` + PlatformCode string `json:"platformCode"` + PlatformName string `json:"platformName"` + Status string `json:"status"` + APIBaseURL string `json:"apiBaseUrl"` + // AdapterType 只允许服务端白名单中的值,避免后台误填导致回调走错协议。 + AdapterType string `json:"adapterType"` + // CallbackSecret 只有提交非空时才覆盖旧密钥,编辑其它字段时前端传空即可。 CallbackSecret string `json:"callbackSecret"` CallbackIPWhitelist []string `json:"callbackIpWhitelist"` - AdapterConfigJSON string `json:"adapterConfigJson"` - SortOrder int32 `json:"sortOrder"` + // AdapterConfigJSON 是厂商扩展配置,保持 JSON 字符串透传给 game-service。 + AdapterConfigJSON string `json:"adapterConfigJson"` + SortOrder int32 `json:"sortOrder"` } type catalogRequest struct { diff --git a/server/admin/internal/modules/userleaderboard/handler.go b/server/admin/internal/modules/userleaderboard/handler.go new file mode 100644 index 00000000..0965cfd4 --- /dev/null +++ b/server/admin/internal/modules/userleaderboard/handler.go @@ -0,0 +1,87 @@ +package userleaderboard + +import ( + "database/sql" + "errors" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func New(walletDB *sql.DB, userDB *sql.DB, roomDB *sql.DB) *Handler { + return &Handler{service: NewService(walletDB, userDB, roomDB)} +} + +func (h *Handler) List(c *gin.Context) { + query, ok := parseQuery(c) + if !ok { + return + } + + result, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), query) + if err != nil { + if errors.Is(err, errInvalidArgument) { + response.BadRequest(c, "榜单参数不正确") + return + } + response.ServerError(c, "获取用户榜单失败") + return + } + response.OK(c, result) +} + +func parseQuery(c *gin.Context) (Query, bool) { + options := shared.ListOptions(c) + boardType := NormalizeBoardType(firstQuery(c, "board_type", "boardType", "type")) + if boardType == "" { + response.BadRequest(c, "榜单类型不正确") + return Query{}, false + } + + period := NormalizePeriod(firstQuery(c, "period", "time_dimension", "timeDimension")) + if period == "" { + response.BadRequest(c, "时间维度不正确") + return Query{}, false + } + + viewerUserID, ok := optionalInt64(firstQuery(c, "viewer_user_id", "viewerUserId", "user_id", "userId")) + if !ok { + response.BadRequest(c, "用户 ID 不正确") + return Query{}, false + } + + return NormalizeQuery(Query{ + BoardType: boardType, + Period: period, + Page: options.Page, + PageSize: options.PageSize, + ViewerUserID: viewerUserID, + }), true +} + +func optionalInt64(raw string) (int64, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, true + } + value, err := strconv.ParseInt(raw, 10, 64) + return value, err == nil && value >= 0 +} + +func firstQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } + } + return "" +} diff --git a/server/admin/internal/modules/userleaderboard/routes.go b/server/admin/internal/modules/userleaderboard/routes.go new file mode 100644 index 00000000..8080e68f --- /dev/null +++ b/server/admin/internal/modules/userleaderboard/routes.go @@ -0,0 +1,15 @@ +package userleaderboard + +import ( + "hyapp-admin-server/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + + protected.GET("/admin/activity/user-leaderboards", middleware.RequirePermission("user-leaderboard:view"), h.List) +} diff --git a/server/admin/internal/modules/userleaderboard/service.go b/server/admin/internal/modules/userleaderboard/service.go new file mode 100644 index 00000000..978fe9cf --- /dev/null +++ b/server/admin/internal/modules/userleaderboard/service.go @@ -0,0 +1,578 @@ +package userleaderboard + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + BoardTypeSent = "sent" + BoardTypeReceived = "received" + BoardTypeRoom = "room" + + PeriodToday = "today" + PeriodWeek = "week" + PeriodMonth = "month" + + maxPageSize = 100 +) + +var errInvalidArgument = errors.New("invalid argument") + +type Service struct { + walletDB *sql.DB + userDB *sql.DB + roomDB *sql.DB +} + +type Query struct { + BoardType string + Period string + Page int + PageSize int + ViewerUserID int64 +} + +type Response struct { + Items []Item `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + BoardType string `json:"board_type"` + Period string `json:"period"` + StartAtMS int64 `json:"start_at_ms"` + EndAtMS int64 `json:"end_at_ms"` + ServerTimeMS int64 `json:"server_time_ms"` + MyRank *Item `json:"my_rank,omitempty"` +} + +type Item struct { + Rank int64 `json:"rank"` + UserID string `json:"user_id,omitempty"` + RoomID string `json:"room_id,omitempty"` + GiftValue int64 `json:"gift_value"` + GiftCount int64 `json:"gift_count"` + TransactionCount int64 `json:"transaction_count"` + LastGiftAtMS int64 `json:"last_gift_at_ms"` + User *User `json:"user,omitempty"` + Room *Room `json:"room,omitempty"` +} + +type User struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id,omitempty"` + Username string `json:"username,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +type Room struct { + RoomID string `json:"room_id"` + RoomShortID string `json:"room_short_id,omitempty"` + Title string `json:"title,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + OwnerUserID string `json:"owner_user_id,omitempty"` + Status string `json:"status,omitempty"` +} + +type userProfile struct { + UserID int64 + DisplayUserID string + Username string + Avatar string +} + +type roomProfile struct { + RoomID string + RoomShortID string + Title string + CoverURL string + OwnerUserID int64 + Status string +} + +func NewService(walletDB *sql.DB, userDB *sql.DB, roomDB *sql.DB) *Service { + return &Service{walletDB: walletDB, userDB: userDB, roomDB: roomDB} +} + +func (s *Service) List(ctx context.Context, appCode string, query Query) (Response, error) { + query = NormalizeQuery(query) + if query.BoardType == "" || query.Period == "" { + return Response{}, errInvalidArgument + } + if s == nil || s.walletDB == nil { + return Response{}, fmt.Errorf("wallet mysql is not configured") + } + + now := time.Now().UTC() + start, end := periodWindow(query.Period, now) + startMS := start.UnixMilli() + endMS := end.UnixMilli() + + total, err := s.count(ctx, appCode, query.BoardType, startMS, endMS) + if err != nil { + return Response{}, err + } + items, err := s.listItems(ctx, appCode, query.BoardType, startMS, endMS, query.PageSize, offset(query.Page, query.PageSize)) + if err != nil { + return Response{}, err + } + + var myRank *Item + if query.ViewerUserID > 0 && query.BoardType != BoardTypeRoom { + item, found, err := s.rankForUser(ctx, appCode, query.BoardType, startMS, endMS, query.ViewerUserID) + if err != nil { + return Response{}, err + } + if found { + myRank = &item + } + } + + if err := s.enrich(ctx, appCode, query.BoardType, items, myRank); err != nil { + return Response{}, err + } + + return Response{ + Items: items, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + BoardType: query.BoardType, + Period: query.Period, + StartAtMS: startMS, + EndAtMS: endMS, + ServerTimeMS: now.UnixMilli(), + MyRank: myRank, + }, nil +} + +func NormalizeBoardType(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "sent", "send", "sender", "gift_sent", "user_sent": + return BoardTypeSent + case "received", "receive", "receiver", "gift_received", "user_received": + return BoardTypeReceived + case "room", "rooms", "room_gift", "room_gifts": + return BoardTypeRoom + default: + return "" + } +} + +func NormalizePeriod(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "today", "day", "daily": + return PeriodToday + case "week", "weekly": + return PeriodWeek + case "month", "monthly": + return PeriodMonth + default: + return "" + } +} + +func NormalizeQuery(query Query) Query { + query.BoardType = NormalizeBoardType(query.BoardType) + query.Period = NormalizePeriod(query.Period) + if query.Page <= 0 { + query.Page = 1 + } + if query.PageSize <= 0 { + query.PageSize = 20 + } + if query.PageSize > maxPageSize { + query.PageSize = maxPageSize + } + return query +} + +func periodWindow(period string, now time.Time) (time.Time, time.Time) { + utcNow := now.UTC() + dayStart := time.Date(utcNow.Year(), utcNow.Month(), utcNow.Day(), 0, 0, 0, 0, time.UTC) + switch period { + case PeriodWeek: + weekday := int(dayStart.Weekday()) + if weekday == 0 { + weekday = 7 + } + return dayStart.AddDate(0, 0, 1-weekday), utcNow + case PeriodMonth: + return time.Date(utcNow.Year(), utcNow.Month(), 1, 0, 0, 0, 0, time.UTC), utcNow + default: + return dayStart, utcNow + } +} + +func (s *Service) count(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64) (int64, error) { + subject, predicate := leaderboardDimension(boardType) + query := fmt.Sprintf(` + WITH gift_facts AS ( + SELECT + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id, + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id, + COALESCE( + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), + 0 + ) AS gift_value, + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count, + created_at_ms + FROM wallet_transactions + WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded' + AND created_at_ms >= ? AND created_at_ms < ? + ), + aggregated AS ( + SELECT %s AS subject_id + FROM gift_facts + WHERE %s + GROUP BY %s + ) + SELECT COUNT(*) FROM aggregated`, subject, predicate, subject) + + var total int64 + err := s.walletDB.QueryRowContext(ctx, query, appCode, startMS, endMS).Scan(&total) + return total, err +} + +func (s *Service) listItems(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64, limit int, offset int) ([]Item, error) { + subject, predicate := leaderboardDimension(boardType) + query := fmt.Sprintf(` + WITH gift_facts AS ( + SELECT + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id, + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id, + COALESCE( + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), + 0 + ) AS gift_value, + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count, + created_at_ms + FROM wallet_transactions + WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded' + AND created_at_ms >= ? AND created_at_ms < ? + ), + aggregated AS ( + SELECT %s AS subject_id, + SUM(gift_value) AS gift_value, + SUM(gift_count) AS gift_count, + COUNT(*) AS transaction_count, + MAX(created_at_ms) AS last_gift_at_ms + FROM gift_facts + WHERE %s + GROUP BY %s + ), + ranked AS ( + SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no, + subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms + FROM aggregated + ) + SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms + FROM ranked + ORDER BY rank_no ASC + LIMIT ? OFFSET ?`, subject, predicate, subject) + + rows, err := s.walletDB.QueryContext(ctx, query, appCode, startMS, endMS, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]Item, 0, limit) + for rows.Next() { + item, err := scanItem(rows, boardType) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Service) rankForUser(ctx context.Context, appCode string, boardType string, startMS int64, endMS int64, userID int64) (Item, bool, error) { + subject, predicate := leaderboardDimension(boardType) + query := fmt.Sprintf(` + WITH gift_facts AS ( + SELECT + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id, + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id, + COALESCE( + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), + 0 + ) AS gift_value, + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count, + created_at_ms + FROM wallet_transactions + WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded' + AND created_at_ms >= ? AND created_at_ms < ? + ), + aggregated AS ( + SELECT %s AS subject_id, + SUM(gift_value) AS gift_value, + SUM(gift_count) AS gift_count, + COUNT(*) AS transaction_count, + MAX(created_at_ms) AS last_gift_at_ms + FROM gift_facts + WHERE %s + GROUP BY %s + ), + ranked AS ( + SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no, + subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms + FROM aggregated + ) + SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms + FROM ranked + WHERE subject_id = ? + LIMIT 1`, subject, predicate, subject) + + row := s.walletDB.QueryRowContext(ctx, query, appCode, startMS, endMS, userID) + item, err := scanItem(row, boardType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Item{}, false, nil + } + return Item{}, false, err + } + return item, true, nil +} + +type itemScanner interface { + Scan(dest ...any) error +} + +func scanItem(scanner itemScanner, boardType string) (Item, error) { + var item Item + var subject string + if err := scanner.Scan(&item.Rank, &subject, &item.GiftValue, &item.GiftCount, &item.TransactionCount, &item.LastGiftAtMS); err != nil { + return Item{}, err + } + if boardType == BoardTypeRoom { + item.RoomID = subject + item.Room = &Room{RoomID: subject} + return item, nil + } + item.UserID = subject + item.User = &User{UserID: subject} + return item, nil +} + +func leaderboardDimension(boardType string) (string, string) { + switch boardType { + case BoardTypeReceived: + return "target_user_id", "target_user_id > 0" + case BoardTypeRoom: + return "room_id", "room_id <> ''" + default: + return "sender_user_id", "sender_user_id > 0" + } +} + +func (s *Service) enrich(ctx context.Context, appCode string, boardType string, items []Item, myRank *Item) error { + if boardType == BoardTypeRoom { + return s.enrichRooms(ctx, appCode, items) + } + + userIDs := make([]int64, 0, len(items)+1) + for _, item := range items { + if id, err := strconv.ParseInt(item.UserID, 10, 64); err == nil && id > 0 { + userIDs = append(userIDs, id) + } + } + if myRank != nil { + if id, err := strconv.ParseInt(myRank.UserID, 10, 64); err == nil && id > 0 { + userIDs = append(userIDs, id) + } + } + profiles, err := s.userProfiles(ctx, appCode, userIDs) + if err != nil { + return err + } + applyUserProfiles(items, profiles) + if myRank != nil { + applyUserProfile(myRank, profiles) + } + return nil +} + +func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { + result := make(map[int64]userProfile, len(userIDs)) + if s == nil || s.userDB == nil || len(userIDs) == 0 { + return result, nil + } + userIDs = uniqueInt64s(userIDs) + args := make([]any, 0, len(userIDs)+1) + args = append(args, appCode) + for _, id := range userIDs { + args = append(args, id) + } + rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` + SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') + FROM users + WHERE app_code = ? AND user_id IN (%s) + `, placeholders(len(userIDs))), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var profile userProfile + if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil { + return nil, err + } + result[profile.UserID] = profile + } + return result, rows.Err() +} + +func applyUserProfiles(items []Item, profiles map[int64]userProfile) { + for i := range items { + applyUserProfile(&items[i], profiles) + } +} + +func applyUserProfile(item *Item, profiles map[int64]userProfile) { + if item == nil { + return + } + id, err := strconv.ParseInt(item.UserID, 10, 64) + if err != nil || id <= 0 { + return + } + if profile, ok := profiles[id]; ok { + item.User = &User{ + UserID: strconv.FormatInt(profile.UserID, 10), + DisplayUserID: profile.DisplayUserID, + Username: profile.Username, + Avatar: profile.Avatar, + } + } +} + +func (s *Service) enrichRooms(ctx context.Context, appCode string, items []Item) error { + roomIDs := make([]string, 0, len(items)) + for _, item := range items { + if item.RoomID != "" { + roomIDs = append(roomIDs, item.RoomID) + } + } + rooms, err := s.roomProfiles(ctx, appCode, roomIDs) + if err != nil { + return err + } + for i := range items { + if room, ok := rooms[items[i].RoomID]; ok { + items[i].Room = &Room{ + RoomID: room.RoomID, + RoomShortID: room.RoomShortID, + Title: room.Title, + CoverURL: room.CoverURL, + OwnerUserID: formatOptionalID(room.OwnerUserID), + Status: room.Status, + } + } + } + return nil +} + +func (s *Service) roomProfiles(ctx context.Context, appCode string, roomIDs []string) (map[string]roomProfile, error) { + result := make(map[string]roomProfile, len(roomIDs)) + if s == nil || s.roomDB == nil || len(roomIDs) == 0 { + return result, nil + } + roomIDs = uniqueStrings(roomIDs) + args := make([]any, 0, len(roomIDs)+1) + args = append(args, appCode) + for _, id := range roomIDs { + args = append(args, id) + } + rows, err := s.roomDB.QueryContext(ctx, fmt.Sprintf(` + SELECT room_id, room_short_id, COALESCE(title, ''), COALESCE(cover_url, ''), owner_user_id, status + FROM room_list_entries + WHERE app_code = ? AND room_id IN (%s) + `, placeholders(len(roomIDs))), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var profile roomProfile + if err := rows.Scan(&profile.RoomID, &profile.RoomShortID, &profile.Title, &profile.CoverURL, &profile.OwnerUserID, &profile.Status); err != nil { + return nil, err + } + result[profile.RoomID] = profile + } + return result, rows.Err() +} + +func offset(page int, pageSize int) int { + if page <= 1 { + return 0 + } + return (page - 1) * pageSize +} + +func placeholders(n int) string { + if n <= 0 { + return "" + } + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} + +func uniqueInt64s(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func formatOptionalID(id int64) string { + if id <= 0 { + return "" + } + return strconv.FormatInt(id, 10) +} diff --git a/server/admin/internal/modules/userleaderboard/service_test.go b/server/admin/internal/modules/userleaderboard/service_test.go new file mode 100644 index 00000000..4eb29819 --- /dev/null +++ b/server/admin/internal/modules/userleaderboard/service_test.go @@ -0,0 +1,38 @@ +package userleaderboard + +import ( + "testing" + "time" +) + +func TestPeriodWindowUsesUTCBoundaries(t *testing.T) { + now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60)) + + todayStart, todayEnd := periodWindow(PeriodToday, now) + if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) { + t.Fatalf("today start = %s", todayStart) + } + if !todayEnd.Equal(now.UTC()) { + t.Fatalf("today end = %s", todayEnd) + } + + weekStart, _ := periodWindow(PeriodWeek, now) + if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) { + t.Fatalf("week start = %s", weekStart) + } + + monthStart, _ := periodWindow(PeriodMonth, now) + if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) { + t.Fatalf("month start = %s", monthStart) + } +} + +func TestNormalizeQueryDefaultsAndCapsPageSize(t *testing.T) { + query := NormalizeQuery(Query{BoardType: "receiver", Period: "monthly", PageSize: 500}) + if query.BoardType != BoardTypeReceived || query.Period != PeriodMonth { + t.Fatalf("query = %+v", query) + } + if query.Page != 1 || query.PageSize != maxPageSize { + t.Fatalf("pagination = page %d pageSize %d", query.Page, query.PageSize) + } +} diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 2b791122..c9732bee 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -98,6 +98,7 @@ var defaultPermissions = []model.Permission{ {Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, {Name: "房间宝箱查看", Code: "room-treasure:view", Kind: "menu"}, {Name: "房间宝箱更新", Code: "room-treasure:update", Kind: "button"}, + {Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"}, {Name: "角色查看", Code: "role:view", Kind: "menu"}, {Name: "角色创建", Code: "role:create", Kind: "button"}, {Name: "角色更新", Code: "role:update", Kind: "button"}, @@ -233,6 +234,7 @@ func (s *Store) seedMenus() error { {ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true}, {ParentID: &activityID, Title: "七日签到", Code: "seven-day-checkin", Path: "/activities/seven-day-checkin", Icon: "event_available", PermissionCode: "seven-day-checkin:view", Sort: 72, Visible: true}, {ParentID: &activityID, Title: "房间宝箱", Code: "room-treasure", Path: "/activities/room-treasure", Icon: "redeem", PermissionCode: "room-treasure:view", Sort: 73, Visible: true}, + {ParentID: &activityID, Title: "用户榜单", Code: "user-leaderboard", Path: "/activities/user-leaderboards", Icon: "leaderboard", PermissionCode: "user-leaderboard:view", Sort: 74, Visible: true}, {ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true}, {ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true}, {ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true}, diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 5bb42b88..18f62ad9 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -31,6 +31,7 @@ import ( "hyapp-admin-server/internal/modules/search" "hyapp-admin-server/internal/modules/sevendaycheckin" "hyapp-admin-server/internal/modules/upload" + "hyapp-admin-server/internal/modules/userleaderboard" "hyapp-admin-server/internal/platform/logging" "hyapp-admin-server/internal/service" @@ -66,6 +67,7 @@ type Handlers struct { Search *search.Handler SevenDayCheckIn *sevendaycheckin.Handler Upload *upload.Handler + UserLeaderboard *userleaderboard.Handler } func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { @@ -106,6 +108,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { upload.RegisterRoutes(protected, h.Upload) search.RegisterRoutes(protected, h.Search) sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn) + userleaderboard.RegisterRoutes(protected, h.UserLeaderboard) return engine } diff --git a/services/game-service/internal/domain/game/game.go b/services/game-service/internal/domain/game/game.go index c2bc81b2..154916c3 100644 --- a/services/game-service/internal/domain/game/game.go +++ b/services/game-service/internal/domain/game/game.go @@ -29,19 +29,24 @@ const ( // Platform 是第三方游戏平台配置事实。 type Platform struct { - AppCode string - PlatformCode string - PlatformName string - Status string - APIBaseURL string - AdapterType string + AppCode string + PlatformCode string + PlatformName string + Status string + // APIBaseURL 是厂商 H5/Auth 入口,按环境在后台改,不放服务配置文件。 + APIBaseURL string + // AdapterType 决定启动参数、签名/解密规则、回包结构和错误码映射。 + AdapterType string + // CallbackSecretCiphertext 存放厂商 key/AppSecret;接口响应不回显明文。 CallbackSecretCiphertext string CallbackSecretConfigured bool - CallbackIPWhitelist []string - AdapterConfigJSON string - SortOrder int32 - CreatedAtMS int64 - UpdatedAtMS int64 + // CallbackIPWhitelist 为空表示不限制;配置后支持单 IP 或 CIDR。 + CallbackIPWhitelist []string + // AdapterConfigJSON 存放 uid_mode/default_lang/token_ttl_seconds 等厂商扩展项。 + AdapterConfigJSON string + SortOrder int32 + CreatedAtMS int64 + UpdatedAtMS int64 } // CatalogItem 是内部稳定 game_id 到第三方 provider_game_id 的映射。 @@ -84,8 +89,9 @@ type AppGame struct { // LaunchableGame 汇总启动时需要的平台和目录配置。 type LaunchableGame struct { CatalogItem - PlatformName string - PlatformStatus string + PlatformName string + PlatformStatus string + // 以下平台字段随 catalog 一起取出,启动链路不再额外查一次平台配置。 APIBaseURL string AdapterType string CallbackSecretCiphertext string @@ -95,14 +101,16 @@ type LaunchableGame struct { // LaunchSession 是 H5 启动会话持久事实,token 只保存 hash。 type LaunchSession struct { - AppCode string - SessionID string - UserID int64 - DisplayUserID string - RoomID string - Scene string - PlatformCode string - GameID string + AppCode string + SessionID string + UserID int64 + // DisplayUserID 用于对外 uid_mode,避免直接暴露内部数字 ID。 + DisplayUserID string + RoomID string + Scene string + PlatformCode string + GameID string + // ProviderGameID 是厂商侧 gameId/gameUid,回调时用它防止跨游戏复用 token。 ProviderGameID string LaunchTokenHash string Status string @@ -113,15 +121,18 @@ type LaunchSession struct { // GameOrder 是 provider_order_id 对应的钱包改账事实。 type GameOrder struct { - AppCode string - OrderID string - PlatformCode string - ProviderOrderID string - ProviderRoundID string - GameID string - ProviderGameID string - UserID int64 - RoomID string + AppCode string + OrderID string + PlatformCode string + // ProviderOrderID 是厂商幂等键,唯一索引用它挡住重复扣款/返奖。 + ProviderOrderID string + // ProviderRoundID 用于关联同一局的投注和返奖。 + ProviderRoundID string + GameID string + ProviderGameID string + UserID int64 + RoomID string + // OpType 只允许钱包语义 debit/credit,厂商枚举在适配器层转换。 OpType string CoinAmount int64 Status string @@ -162,20 +173,24 @@ type CallbackLog struct { Operation string RequestID string ProviderRequestID string - SignatureValid bool - RequestHash string - ResponseCode string - ResponseBodyHash string - Status string - CreatedAtMS int64 + // SignatureValid 用来区分验签失败、业务失败和系统失败,便于联调排错。 + SignatureValid bool + // RequestHash/ResponseBodyHash 避免把 token、签名、用户资料等敏感原文直接进日志表。 + RequestHash string + ResponseCode string + ResponseBodyHash string + Status string + CreatedAtMS int64 } // RepairOrder 是三方补单请求的最小审计事实;首版可只落库不改账。 type RepairOrder struct { - AppCode string - RepairID string - PlatformCode string + AppCode string + RepairID string + PlatformCode string + // ProviderOrderID 唯一约束保证重复补单只产生一条审计事实。 ProviderOrderID string + // Reason 标记来源厂商和接口,后续补偿任务可以按 reason 选择不同策略。 Reason string Status string OperatorAdminID int64 diff --git a/services/game-service/internal/service/game/leadercc_adapter.go b/services/game-service/internal/service/game/leadercc_adapter.go index dd51a054..b1c072b8 100644 --- a/services/game-service/internal/service/game/leadercc_adapter.go +++ b/services/game-service/internal/service/game/leadercc_adapter.go @@ -15,6 +15,7 @@ import ( ) const ( + // 灵仙接口使用 errorCode 作为业务结果,HTTP 成功不代表订单成功。 leaderccCodeOK = 0 leaderccCodeTimeout = 4000 leaderccCodeInsufficientBalance = 4004 @@ -27,13 +28,17 @@ const ( leaderccDefaultTokenTTL = 24 * time.Hour ) +// leaderccAdapterConfig 对应后台平台配置里的 adapter_config_json。 +// 目前只放跨环境可调整字段,避免每接一个灵仙游戏或切域名都发版。 type leaderccAdapterConfig struct { + // uid_mode 必须和启动 URL、userinfo、change_coin 三处保持一致。 UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` } func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { + // Platform 用于回调,LaunchableGame 用于启动;两处共用同一套 JSON 解析规则。 var raw string switch typed := value.(type) { case gamedomain.Platform: @@ -49,6 +54,7 @@ func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { } func (c leaderccAdapterConfig) TokenTTL() time.Duration { + // 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。 if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } @@ -56,11 +62,13 @@ func (c leaderccAdapterConfig) TokenTTL() time.Duration { } func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { + // 灵仙是明文 JSON + MD5 签名,所以先做 IP 白名单,再进入具体接口校验签名。 if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { raw, contentType := leaderccJSON(leaderccCodeRiskRejected, "ip forbidden", map[string]any{}) return raw, contentType, strconv.Itoa(leaderccCodeRiskRejected), false, "", nil } switch operation { + // operation 由统一 callback 路由传入,兼容不同命名可以减少网关层特殊分支。 case "userinfo", "user_info", "get_user_info": return s.handleLeaderCCUserInfo(ctx, app, platform, req) case "change_coin", "change_balance", "update_coin": @@ -74,6 +82,7 @@ func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platf } func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { + // userinfo 签名顺序来自灵仙文档:gameId + uid + token + roomId + key。 var body struct { GameID string `json:"gameId"` UID string `json:"uid"` @@ -84,11 +93,13 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil { return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "") } + // key 存在 callback_secret 字段里;后台不回显明文,更新时才覆盖。 if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.GameID, body.UID, body.Token, body.RoomID) { return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, "") } config := leaderccConfigFromPlatform(platform) session, err := s.validLeaderCCSession(ctx, app, body.Token) + // token 通过后仍要校验 uid/gameId,防止合法 token 被跨玩家或跨游戏复用。 if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) { return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "") } @@ -97,6 +108,7 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, "") } raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{ + // 灵仙字段名是 coin;这里直接返回钱包可用 COIN,避免在适配器层引入换算。 "uid": body.UID, "nickname": snapshot.Nickname, "avatar": snapshot.Avatar, @@ -106,6 +118,7 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo } func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) { + // change_coin 是灵仙唯一改账入口;orderId 是幂等键,roundId 用来关联同一轮投注/返奖。 var body struct { OrderID string `json:"orderId"` GameID string `json:"gameId"` @@ -123,20 +136,25 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "") } providerOrderID := strings.TrimSpace(body.OrderID) + // 文档签名顺序:orderId + gameId + roundId + uid + coin + type + rewardType + token + winId + roomId + key。 if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.Type), 10), strconv.FormatInt(int64(body.RewardType), 10), body.Token, body.WinID, body.RoomID) { return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID) } config := leaderccConfigFromPlatform(platform) session, err := s.validLeaderCCSession(ctx, app, body.Token) + // 灵仙 change_coin 既带 token 又带 uid/gameId,三者都必须命中同一条启动会话。 if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) { return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID) } opType := leaderccOpType(body.Type) + // type=1 扣款、type=2 加款;coin=0 没有实际改账,但仍会按幂等订单记成功。 if providerOrderID == "" || opType == "" || body.Coin < 0 { return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID) } + // 钱包改账和幂等逻辑与 Yomi 一致,厂商差异只停留在签名和字段映射层。 result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.RoundID, opType, body.Coin, body.RoomID, requestHash) if err != nil { + // 灵仙余额不足需要返回 4004;其它内部错误在 leaderccCodeFromError 里统一映射。 code := leaderccCodeFromError(err) message := leaderccMessageFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) @@ -148,6 +166,7 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat } func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { + // 补单接口必须提供给厂商,但当前阶段只记录,不自动加钱,避免绕过钱包订单幂等。 var body struct { OrderID string `json:"orderId"` GameID string `json:"gameId"` @@ -163,12 +182,14 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "") } providerOrderID := strings.TrimSpace(body.OrderID) + // 文档签名顺序:orderId + gameId + roundId + uid + coin + rewardType + winId + roomId + key。 if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.RewardType), 10), body.WinID, body.RoomID) { return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID) } if providerOrderID == "" || strings.TrimSpace(body.GameID) == "" || strings.TrimSpace(body.UID) == "" || body.Coin < 0 { return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID) } + // repair_id 稳定派生,厂商重复补单会命中唯一索引并保持幂等。 _, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{ AppCode: app, RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID), @@ -185,6 +206,7 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla } func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) { + // 灵仙和 Yomi 都复用 game_launch_sessions;差异在字段名,不在 token 存储方式。 session, err := s.validYomiSession(ctx, app, token) if err != nil { return gamedomain.LaunchSession{}, err @@ -193,6 +215,7 @@ func (s *Service) validLeaderCCSession(ctx context.Context, app string, token st } func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAdapterConfig, uid string, gameID string) bool { + // gameId 对应内部目录里的 provider_game_id;uid 取值由 uid_mode 决定。 if strings.TrimSpace(gameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) { return false } @@ -200,6 +223,7 @@ func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAda } func leaderccOpType(value int32) string { + // 厂商枚举在适配器内结束,下游钱包只认识 debit/credit。 switch value { case 1: return "debit" @@ -211,18 +235,21 @@ func leaderccOpType(value int32) string { } func decodeLeaderCCJSON(raw []byte, out any) error { + // UseNumber 保持大订单号/局号不被 JSON float64 解析损坏,虽然当前结构体多数字段是 string。 decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.UseNumber() return decoder.Decode(out) } func leaderccSignValid(actual string, key string, parts ...string) bool { + // 灵仙签名是“按文档字段顺序拼接所有字段 + key”后取 MD5 小写 hex。 key = strings.TrimSpace(key) if key == "" { return false } joined := strings.Builder{} for _, part := range parts { + // 厂商样例没有分隔符;这里 trim 只清理传输空白,不改变字段顺序。 joined.WriteString(strings.TrimSpace(part)) } joined.WriteString(key) @@ -232,11 +259,13 @@ func leaderccSignValid(actual string, key string, parts ...string) bool { } func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { + // signatureValid/providerRequestID 会写入 callback log,帮助后续排查签名和重复订单问题。 raw, contentType := leaderccJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func leaderccJSON(errorCode int, message string, data any) ([]byte, string) { + // 灵仙成功可以不带 message;失败时带 message 方便联调定位。 payload := map[string]any{"errorCode": errorCode} if data != nil { payload["data"] = data @@ -248,6 +277,7 @@ func leaderccJSON(errorCode int, message string, data any) ([]byte, string) { } func leaderccCodeFromError(err error) int { + // 内部错误码只在适配器边界映射为灵仙错误码,业务层不依赖厂商语义。 switch { case err == nil: return leaderccCodeOK diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index 5f56778f..d3cc5b3f 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -185,6 +185,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) token := "gt_" + randomHex(24) sessionTTL := s.config.LaunchSessionTTL + // 三方游戏会在 H5 运行期间持续回调 token;厂商适配器可以把默认 15 分钟启动 TTL 拉长。 if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) { sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL()) } else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) { @@ -192,15 +193,16 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch } expiresAt := now.Add(sessionTTL).UnixMilli() session := gamedomain.LaunchSession{ - AppCode: command.AppCode, - SessionID: sessionID, - UserID: command.UserID, - DisplayUserID: command.DisplayUserID, - RoomID: strings.TrimSpace(command.RoomID), - Scene: command.Scene, - PlatformCode: game.PlatformCode, - GameID: game.GameID, - ProviderGameID: game.ProviderGameID, + AppCode: command.AppCode, + SessionID: sessionID, + UserID: command.UserID, + DisplayUserID: command.DisplayUserID, + RoomID: strings.TrimSpace(command.RoomID), + Scene: command.Scene, + PlatformCode: game.PlatformCode, + GameID: game.GameID, + ProviderGameID: game.ProviderGameID, + // 明文 token 只返回给客户端和厂商,库里只保存 hash,回调时通过 hash 查 session。 LaunchTokenHash: stableHash(token), Status: gamedomain.SessionActive, ExpiresAtMS: expiresAt, @@ -235,6 +237,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques app := appcode.Normalize(req.GetMeta().GetAppCode()) ctx = appcode.WithContext(ctx, app) operation := strings.ToLower(strings.TrimSpace(req.GetOperation())) + // requestHash 是回调原文摘要,用于审计和钱包幂等校验,避免落库保存完整敏感 body。 requestHash := stableHashBytes(req.GetRawBody()) callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano())) @@ -245,6 +248,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques var statusCode string var err error if platform, platformErr := s.repository.GetPlatform(ctx, app, req.GetPlatformCode()); platformErr == nil { + // adapter_type 是三方协议边界:新增厂商时只新增适配器,不改钱包/用户主流程。 switch { case strings.EqualFold(platform.AdapterType, gamedomain.AdapterYomiV4): responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash) @@ -254,6 +258,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash) } } else { + // 找不到平台时退回 demo,保留旧测试入口行为,避免管理后台未配置时直接打断联调。 responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash) } responseHash := stableHashBytes(responseBody) @@ -278,6 +283,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques Status: logStatus, CreatedAtMS: s.now().UnixMilli(), }) + // InsertCallbackLog 失败不影响厂商响应;回调主链路优先保证游戏侧不超时。 return responseBody, contentType, err } @@ -537,6 +543,7 @@ func normalizeScene(scene string) string { func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) { base := strings.TrimSpace(game.APIBaseURL) if base == "" { + // demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。 base = "https://game.example.local/h5" } parsed, err := url.Parse(base) @@ -545,6 +552,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes } query := parsed.Query() if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) { + // Yomi 的启动地址是 auth/gateway,用户合法性通过 platAuthCode 在服务端 gettoken 二次校验。 config := yomiConfigFromPlatform(game) lang := config.DefaultLang if lang == "" { @@ -554,6 +562,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes query.Set("platAuthCode", token) query.Set("platUserId", externalUserID(session, config.UIDMode)) query.Set("lang", lang) + // Yomi gameLevelUid 语义类似房间/场次,没有后台配置时必须显式传 0。 if config.GameLevelUID > 0 { query.Set("gameLevelUid", strconv.FormatInt(config.GameLevelUID, 10)) } else { @@ -566,6 +575,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes return parsed.String(), nil } if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) { + // 灵仙启动只需要 uid/token/lang/roomid,后续 userinfo/change_coin 通过 token + MD5 签名校验。 config := leaderccConfigFromPlatform(game) lang := config.DefaultLang if lang == "" { @@ -580,6 +590,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes parsed.RawQuery = query.Encode() return parsed.String(), nil } + // demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。 query.Set("session_id", session.SessionID) query.Set("session_token", token) query.Set("game_id", session.GameID) diff --git a/services/game-service/internal/service/game/yomi_adapter.go b/services/game-service/internal/service/game/yomi_adapter.go index ae7d3270..623bcc65 100644 --- a/services/game-service/internal/service/game/yomi_adapter.go +++ b/services/game-service/internal/service/game/yomi_adapter.go @@ -19,6 +19,7 @@ import ( ) const ( + // Yomi 返回码必须按厂商文档透传,游戏侧会直接根据这些数字决定是否重试或弹错。 yomiCodeOK = 200 yomiCodeSystemError = 9998 yomiCodeInsufficientBalance = 10003 @@ -32,14 +33,20 @@ const ( yomiDefaultTokenTTL = 24 * time.Hour ) +// yomiAdapterConfig 是后台 adapter_config_json 的结构化视图。 +// 这类厂商字段必须放在 DB 配置里,后续切测试/正式环境或新增游戏档位无需重启服务。 type yomiAdapterConfig struct { + // uid_mode 控制给厂商的用户标识来源: + // 空或 user_id 使用内部数字 user_id;display_user_id 使用 App 展示 ID。 UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` - GameLevelUID int64 `json:"game_level_uid"` + // game_level_uid 对应 Yomi 房间/场次,没有配置时按文档传 0。 + GameLevelUID int64 `json:"game_level_uid"` } func yomiConfigFromPlatform(value any) yomiAdapterConfig { + // 启动链路拿到的是 LaunchableGame,回调链路拿到的是 Platform;这里统一解析,避免两处配置规则漂移。 var raw string switch typed := value.(type) { case gamedomain.Platform: @@ -55,6 +62,7 @@ func yomiConfigFromPlatform(value any) yomiAdapterConfig { } func (c yomiAdapterConfig) TokenTTL() time.Duration { + // Yomi 文档建议 token 至少 24 小时;后台可以调大或调小,但空配置必须有安全默认值。 if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } @@ -62,6 +70,7 @@ func (c yomiAdapterConfig) TokenTTL() time.Duration { } func externalUserID(session gamedomain.LaunchSession, uidMode string) string { + // 三方通常会把 uid 展示给玩家或写入订单,切换 uid_mode 后必须保持启动和回调校验一致。 if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" { return strings.TrimSpace(session.DisplayUserID) } @@ -69,10 +78,12 @@ func externalUserID(session gamedomain.LaunchSession, uidMode string) string { } func (s *Service) handleYomiOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { + // IP 白名单先于解密执行,可以挡住明显非厂商来源的请求,减少密钥探测面。 if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{}) return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil } + // Yomi 请求体是 JSON -> AES-GCM -> base64 URL 编码,body 解不开就按签名错误返回。 body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext) if err != nil { raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{}) @@ -80,6 +91,7 @@ func (s *Service) handleYomiOperation(ctx context.Context, app string, platform } switch operation { + // operation 来自网关路由尾段,不同部署可能保留 api/ 前缀,所以兼容常见写法。 case "gettoken", "get_token", "api/gettoken": return s.handleYomiGetToken(ctx, app, platform, req, body) case "userinfo", "user_info", "api/userinfo": @@ -95,6 +107,8 @@ func (s *Service) handleYomiOperation(ctx context.Context, app string, platform } func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) { + // gettoken 是 Yomi 用启动链接里的 platAuthCode 换平台 token 的闭环校验。 + // 当前实现直接复用启动 token:Yomi 后续 userinfo/change_balance 都必须带同一个 platToken。 var body struct { GameUID json.Number `json:"gameUid"` Lang string `json:"lang"` @@ -108,6 +122,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g } config := yomiConfigFromPlatform(platform) session, err := s.validYomiSession(ctx, app, body.PlatAuthCode) + // 同时校验 token、用户和游戏,避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") } @@ -116,6 +131,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "") } data := map[string]any{ + // platUserId 必须原样返回,Yomi 文档明确要求保持业务含义稳定。 "platUserId": body.PlatUserID, "platUserOpenId": session.DisplayUserID, "nickname": snapshot.Nickname, @@ -129,6 +145,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g } func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) { + // userinfo 是游戏侧进入房间或中途刷新玩家资料时调用,只读用户资料和当前金币。 var body struct { PlatUserID string `json:"platUserId"` PlatToken string `json:"platToken"` @@ -140,6 +157,7 @@ func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform g } config := yomiConfigFromPlatform(platform) session, err := s.validYomiSession(ctx, app, body.PlatToken) + // userinfo 没有 gameUid 时只校验用户;change_balance 会额外校验游戏 ID。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") } @@ -159,6 +177,7 @@ func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform g } func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) { + // change_balance 是唯一真正改钱包的 Yomi 回调;requestId 是厂商幂等键,必须落库去重。 var body struct { RequestID json.Number `json:"requestId"` PlatUserID string `json:"platUserId"` @@ -179,15 +198,18 @@ func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platf providerOrderID := strings.TrimSpace(body.RequestID.String()) config := yomiConfigFromPlatform(platform) session, err := s.validYomiSession(ctx, app, body.PlatToken) + // 改账必须绑定 token、uid、gameUid 三个维度,防止跨用户、跨游戏复用合法 token。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID) } opType := yomiOpType(body.ReasonType) + // reasonType=1 扣款,reasonType=2 加款;gold=0 是免费局/未中奖的合法结算。 if providerOrderID == "" || opType == "" || body.Gold < 0 { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID) } result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash) if err != nil { + // 厂商要求失败也返回当前余额,查不到时 bestEffortCoinBalance 会退化为 0。 code := yomiCodeFromError(err) message := yomiMessageFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) @@ -199,6 +221,7 @@ func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platf } func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) { + // 补单接口首版只做简单校验和审计落库,不直接补钱,符合当前“先写 Log”边界。 var body struct { RequestID json.Number `json:"requestId"` OrderID string `json:"orderId"` @@ -214,6 +237,7 @@ func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platfor return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } providerOrderID := strings.TrimSpace(body.OrderID) + // 不同厂商/版本可能只给 requestId,这里把 orderId 和 requestId 都兼容成 providerOrderID。 if providerOrderID == "" { providerOrderID = strings.TrimSpace(body.RequestID.String()) } @@ -240,6 +264,7 @@ type yomiCoinChangeResult struct { } func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) { + // order_id 用平台订单号稳定派生,保证厂商重试、服务重启、并发请求都命中同一条订单。 order := gamedomain.GameOrder{ AppCode: app, OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID), @@ -260,9 +285,11 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game return yomiCoinChangeResult{}, err } if exists && order.Status == gamedomain.OrderStatusSucceeded { + // 已成功订单按幂等重放处理,直接返回历史余额,不能再次调用钱包。 return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter}, nil } if amount == 0 { + // Yomi 文档允许投注/返奖金额为 0;这类订单仍需标记成功,但不能产生钱包流水。 balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil { return yomiCoinChangeResult{}, err @@ -272,6 +299,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game if s.wallet == nil { return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured") } + // wallet-service 持有真实余额,game-service 只传入平台订单和幂等命令 ID。 walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ RequestId: req.GetMeta().GetRequestId(), AppCode: app, @@ -290,6 +318,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game if err != nil { status := gamedomain.OrderStatusFailed if xerr.IsCode(err, xerr.IdempotencyConflict) { + // 钱包侧发现幂等冲突时单独标记,便于后续运营排查重复或参数不一致订单。 status = gamedomain.OrderStatusConflict } _ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs) @@ -302,12 +331,14 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game } type yomiUserSnapshot struct { + // 厂商只需要展示资料和金币,不把内部用户完整模型暴露给适配器。 Nickname string Avatar string Balance int64 } func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) { + // 用户资料走 user-service,金币走 wallet-service,避免 game-service 缓存造成余额不准。 if s.user == nil || s.wallet == nil { return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured") } @@ -331,6 +362,7 @@ func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1. } func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 { + // 余额查询失败时不阻断错误响应;厂商协议更需要一个可解析的 balance 字段。 if s.wallet == nil || userID <= 0 { return 0 } @@ -352,6 +384,7 @@ func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, request } func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) { + // 启动 token 明文只出现在启动 URL 和厂商回调里,数据库里按 hash 查询。 if strings.TrimSpace(token) == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } @@ -366,6 +399,7 @@ func (s *Service) validYomiSession(ctx context.Context, app string, token string } func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool { + // gameUID 允许为空是为了兼容 userinfo;只要厂商传了就必须和启动会话里的 provider_game_id 一致。 if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) { return false } @@ -374,6 +408,7 @@ func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConf } func yomiOpType(reasonType int32) string { + // 转成钱包服务理解的 debit/credit,厂商枚举不向下游扩散。 switch reasonType { case 1: return "debit" @@ -385,12 +420,14 @@ func yomiOpType(reasonType int32) string { } func decodeYomiJSON(raw []byte, out any) error { + // UseNumber 避免 requestId/gameRoundId 这类 64 位数字经过 float64 丢精度。 decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.UseNumber() return decoder.Decode(out) } func decryptYomiBody(raw []byte, secret string) ([]byte, error) { + // 文档给的 AppSecret 是 32 字节字符串,正好对应 AES-256 key。 secret = strings.TrimSpace(secret) if secret == "" { return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty") @@ -410,12 +447,14 @@ func decryptYomiBody(raw []byte, secret string) ([]byte, error) { if len(decoded) <= gcm.NonceSize() { return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short") } + // Yomi 把 IV/nonce 放在密文前 12 位,剩余部分是 GCM ciphertext+tag。 nonce := decoded[:gcm.NonceSize()] ciphertext := decoded[gcm.NonceSize():] return gcm.Open(nil, nonce, ciphertext, nil) } func decodeYomiBase64(value string) ([]byte, error) { + // 文档写 URL base64,但测试工具经常混用 padded/raw/std,这里按宽松解析提升联调成功率。 encodings := []*base64.Encoding{ base64.URLEncoding, base64.RawURLEncoding, @@ -434,6 +473,7 @@ func decodeYomiBase64(value string) ([]byte, error) { } func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool { + // 空白名单表示不限制;配置后支持单 IP 和 CIDR,便于同时兼容测试环境和正式机房。 if len(whitelist) == 0 { return true } @@ -457,6 +497,7 @@ func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool { } func callbackRequestIP(req *gamev1.CallbackRequest) string { + // 网关会把真实来源 IP 写在转发头里;优先取第一段 X-Forwarded-For。 for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} { if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" { return strings.TrimSpace(strings.Split(raw, ",")[0]) @@ -475,11 +516,13 @@ func callbackRequestIP(req *gamev1.CallbackRequest) string { } func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { + // 返回值同时服务“HTTP 响应”和“回调日志”,所以这里保留签名状态和平台请求号。 raw, contentType := yomiJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func yomiJSON(code int, message string, data any) ([]byte, string) { + // Yomi 成功 message 固定 success;失败 message 给具体错误原因,data 必须始终是对象。 if strings.TrimSpace(message) == "" { message = "success" } @@ -490,6 +533,7 @@ func yomiJSON(code int, message string, data any) ([]byte, string) { } func yomiCodeFromError(err error) int { + // 内部错误码在适配器边界转换成厂商错误码,避免污染业务服务的错误模型。 switch { case err == nil: return yomiCodeOK @@ -523,6 +567,7 @@ func yomiMessageFromError(err error) string { } func firstNonEmpty(values ...string) string { + // roomId 这类字段优先使用回调里的实时值,缺失时回退启动会话里的值。 for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 07ee112c..89e963d3 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -312,6 +312,7 @@ func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQ } func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) { + // 启动游戏时一次性取出 catalog + platform 配置,避免拿到游戏后再查平台导致配置不一致。 row := r.db.QueryRowContext(ctx, `SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category, c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.min_coin, c.status, @@ -339,6 +340,7 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game } func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) { + // 回调入口只知道 platform_code,必须按 app_code 隔离查询,防止多租户平台编码冲突。 row := r.db.QueryRowContext(ctx, `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'), callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'), @@ -701,6 +703,7 @@ func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Pla if adapterConfig == "" { adapterConfig = "{}" } + // callback_secret_ciphertext 空值表示“保持旧密钥”,这样后台编辑 URL/白名单时不会误清密钥。 _, err := r.db.ExecContext(ctx, `INSERT INTO game_platforms ( app_code, platform_code, platform_name, status, api_base_url, adapter_type, @@ -839,6 +842,7 @@ func scanPlatform(scanner catalogScanner) (gamedomain.Platform, error) { if err := scanner.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return gamedomain.Platform{}, err } + // 只把“是否已配置”暴露给上层,密钥明文不再通过 DTO/Proto 回给后台。 item.CallbackSecretConfigured = strings.TrimSpace(item.CallbackSecretCiphertext) != "" _ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist) return item, nil diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 058608b2..a2e0f696 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -257,15 +257,16 @@ func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame { func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform { return &gamev1.GamePlatform{ - AppCode: item.AppCode, - PlatformCode: item.PlatformCode, - PlatformName: item.PlatformName, - Status: item.Status, - ApiBaseUrl: item.APIBaseURL, - SortOrder: item.SortOrder, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, - AdapterType: item.AdapterType, + AppCode: item.AppCode, + PlatformCode: item.PlatformCode, + PlatformName: item.PlatformName, + Status: item.Status, + ApiBaseUrl: item.APIBaseURL, + SortOrder: item.SortOrder, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + AdapterType: item.AdapterType, + // callback_secret 明文只允许写入,不允许通过列表/详情接口回显到后台前端。 CallbackSecretSet: item.CallbackSecretConfigured, CallbackIpWhitelist: item.CallbackIPWhitelist, AdapterConfigJson: item.AdapterConfigJSON, @@ -277,12 +278,13 @@ func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform { return gamedomain.Platform{} } return gamedomain.Platform{ - AppCode: item.GetAppCode(), - PlatformCode: item.GetPlatformCode(), - PlatformName: item.GetPlatformName(), - Status: item.GetStatus(), - APIBaseURL: item.GetApiBaseUrl(), - AdapterType: item.GetAdapterType(), + AppCode: item.GetAppCode(), + PlatformCode: item.GetPlatformCode(), + PlatformName: item.GetPlatformName(), + Status: item.GetStatus(), + APIBaseURL: item.GetApiBaseUrl(), + AdapterType: item.GetAdapterType(), + // 更新接口只有在 callback_secret 非空时才会覆盖旧密钥,空值表示保留原密钥。 CallbackSecretCiphertext: item.GetCallbackSecret(), CallbackSecretConfigured: item.GetCallbackSecretSet(), CallbackIPWhitelist: item.GetCallbackIpWhitelist(), @@ -335,6 +337,7 @@ func catalogFromProto(item *gamev1.GameCatalogItem) gamedomain.CatalogItem { } func normalizePlatform(item gamedomain.Platform) (gamedomain.Platform, error) { + // gRPC 边界只做通用字段清洗,厂商 JSON 的具体语义留给 adapter 解析。 item.PlatformCode = strings.TrimSpace(item.PlatformCode) item.PlatformName = strings.TrimSpace(item.PlatformName) item.Status = normalizeStatus(item.Status) @@ -387,6 +390,7 @@ func normalizeStatus(status string) string { } func normalizeAdapterType(adapterType string) string { + // 允许的 adapter 必须在这里登记,后台传错值时直接拒绝,避免落库后回调走错协议。 switch strings.ToLower(strings.TrimSpace(adapterType)) { case "", gamedomain.AdapterDemo: return gamedomain.AdapterDemo diff --git a/services/gateway-service/configs/config.docker.yaml b/services/gateway-service/configs/config.docker.yaml index e8851af5..d0c5c314 100644 --- a/services/gateway-service/configs/config.docker.yaml +++ b/services/gateway-service/configs/config.docker.yaml @@ -54,6 +54,9 @@ grpc_client: app_config: # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" +leaderboard: + # 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。 + wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" auth_rate_limit: # public auth 固定窗口频控;Redis 不可用时 gateway 启动失败,避免失去入口保护。 enabled: true diff --git a/services/gateway-service/configs/config.tencent.example.yaml b/services/gateway-service/configs/config.tencent.example.yaml index bc61b45e..2b45543c 100644 --- a/services/gateway-service/configs/config.tencent.example.yaml +++ b/services/gateway-service/configs/config.tencent.example.yaml @@ -51,6 +51,9 @@ grpc_client: app_config: # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" +leaderboard: + # 活动用户榜单只读钱包送礼事实;线上建议配置只读账号。 + wallet_mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" auth_rate_limit: # public auth 固定窗口频控;Redis 不可用时 gateway 启动失败,避免失去入口保护。 enabled: true diff --git a/services/gateway-service/configs/config.yaml b/services/gateway-service/configs/config.yaml index 7b2003cd..70ee9b8b 100644 --- a/services/gateway-service/configs/config.yaml +++ b/services/gateway-service/configs/config.yaml @@ -54,6 +54,9 @@ grpc_client: app_config: # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" +leaderboard: + # 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。 + wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" auth_rate_limit: # public auth 固定窗口频控;Redis 不可用时 gateway 启动失败,避免失去入口保护。 enabled: true diff --git a/services/gateway-service/internal/app/app.go b/services/gateway-service/internal/app/app.go index 0cc72b25..cf2ee846 100644 --- a/services/gateway-service/internal/app/app.go +++ b/services/gateway-service/internal/app/app.go @@ -2,6 +2,7 @@ package app import ( "context" + "database/sql" "errors" "fmt" "net" @@ -11,6 +12,7 @@ import ( "sync" "time" + _ "github.com/go-sql-driver/mysql" "google.golang.org/grpc" "hyapp/pkg/grpcclient" "hyapp/pkg/tencentcos" @@ -32,6 +34,7 @@ type App struct { activityConn *grpc.ClientConn gameConn *grpc.ClientConn appConfig *appconfig.MySQLReader + leaderboard *sql.DB redisClose func() error health *healthcheck.State closeOnce sync.Once @@ -106,6 +109,18 @@ func New(cfg config.Config) (*App, error) { _ = gameConn.Close() return nil, err } + leaderboardWalletDB, err := openLeaderboardWalletDB(cfg.Leaderboard) + if err != nil { + if appConfigReader != nil { + _ = appConfigReader.Close() + } + _ = roomConn.Close() + _ = userConn.Close() + _ = walletConn.Close() + _ = activityConn.Close() + _ = gameConn.Close() + return nil, err + } handler := httptransport.NewHandlerWithConfig(roomClient, roomGuardClient, userClient, userIdentityClient, httptransport.TencentIMConfig{ SDKAppID: cfg.TencentIM.SDKAppID, SecretKey: cfg.TencentIM.SecretKey, @@ -128,6 +143,7 @@ func New(cfg config.Config) (*App, error) { handler.SetSevenDayCheckInClient(sevenDayCheckInClient) handler.SetBroadcastClient(broadcastClient) handler.SetGameClient(gameClient) + handler.SetLeaderboardWalletDB(leaderboardWalletDB) if appConfigReader != nil { handler.SetAppConfigReader(appConfigReader) } @@ -152,6 +168,9 @@ func New(cfg config.Config) (*App, error) { if appConfigReader != nil { _ = appConfigReader.Close() } + if leaderboardWalletDB != nil { + _ = leaderboardWalletDB.Close() + } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() @@ -188,6 +207,9 @@ func New(cfg config.Config) (*App, error) { if appConfigReader != nil { _ = appConfigReader.Close() } + if leaderboardWalletDB != nil { + _ = leaderboardWalletDB.Close() + } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() @@ -203,6 +225,9 @@ func New(cfg config.Config) (*App, error) { if appConfigReader != nil { _ = appConfigReader.Close() } + if leaderboardWalletDB != nil { + _ = leaderboardWalletDB.Close() + } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() @@ -233,6 +258,9 @@ func New(cfg config.Config) (*App, error) { if appConfigReader != nil { _ = appConfigReader.Close() } + if leaderboardWalletDB != nil { + _ = leaderboardWalletDB.Close() + } _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() @@ -262,6 +290,7 @@ func New(cfg config.Config) (*App, error) { activityConn: activityConn, gameConn: gameConn, appConfig: appConfigReader, + leaderboard: leaderboardWalletDB, redisClose: redisClose, health: healthState, }, nil @@ -275,6 +304,14 @@ func openAppConfigReader(cfg config.AppConfigConfig) (*appconfig.MySQLReader, er return appconfig.OpenMySQLReader(cfg.MySQLDSN) } +func openLeaderboardWalletDB(cfg config.LeaderboardConfig) (*sql.DB, error) { + if strings.TrimSpace(cfg.WalletMySQLDSN) == "" { + return nil, nil + } + + return sql.Open("mysql", strings.TrimSpace(cfg.WalletMySQLDSN)) +} + func configureAuthRateLimit(handler *httptransport.Handler, cfg config.AuthRateLimitConfig, rateLimitConfig httptransport.AuthRateLimitConfig) (func() error, error) { if !rateLimitConfig.Enabled { // 显式关闭频控时仍记录配置,避免 handler 使用默认开启策略。 @@ -376,6 +413,9 @@ func (a *App) Close() error { if a.appConfig != nil { err = errors.Join(err, a.appConfig.Close()) } + if a.leaderboard != nil { + err = errors.Join(err, a.leaderboard.Close()) + } if a.redisClose != nil { err = errors.Join(err, a.redisClose()) } diff --git a/services/gateway-service/internal/config/config.go b/services/gateway-service/internal/config/config.go index 33065c7a..be801ff8 100644 --- a/services/gateway-service/internal/config/config.go +++ b/services/gateway-service/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"` LoginRisk LoginRiskConfig `yaml:"login_risk"` AppConfig AppConfigConfig `yaml:"app_config"` + Leaderboard LeaderboardConfig `yaml:"leaderboard"` TencentIM TencentIMConfig `yaml:"tencent_im"` TencentRTC TencentRTCConfig `yaml:"tencent_rtc"` TencentCOS TencentCOSConfig `yaml:"tencent-cos"` @@ -113,6 +114,12 @@ type AppConfigConfig struct { MySQLDSN string `yaml:"mysql_dsn"` } +// LeaderboardConfig 描述 App 活动榜单读取钱包送礼事实的只读数据源。 +type LeaderboardConfig struct { + // WalletMySQLDSN 指向钱包库 hyapp_wallet;只读取 wallet_transactions 的 gift_debit 事实。 + WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` +} + // AuthRateLimitConfig 控制 gateway public auth 入口的 Redis 频控。 type AuthRateLimitConfig struct { // Enabled 为 false 时关闭 gateway auth 入口频控。 @@ -248,6 +255,9 @@ func Default() Config { AppConfig: AppConfigConfig{ MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", }, + Leaderboard: LeaderboardConfig{ + WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", + }, AuthRateLimit: AuthRateLimitConfig{ Enabled: true, RedisAddr: "127.0.0.1:13379", @@ -361,6 +371,7 @@ func (cfg *Config) Normalize() error { RetryableStatusCodes: runtimeGRPC.RetryableStatusCodes, } cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN) + cfg.Leaderboard.WalletMySQLDSN = strings.TrimSpace(cfg.Leaderboard.WalletMySQLDSN) cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr) cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix) if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr == "" { diff --git a/services/gateway-service/internal/transport/http/activityapi/handler.go b/services/gateway-service/internal/transport/http/activityapi/handler.go index ce2b462d..d1340387 100644 --- a/services/gateway-service/internal/transport/http/activityapi/handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/handler.go @@ -1,6 +1,8 @@ package activityapi import ( + "database/sql" + "hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/transport/http/httproutes" ) @@ -11,7 +13,9 @@ type Handler struct { taskClient client.TaskClient growthLevelClient client.GrowthLevelClient achievementClient client.AchievementClient + userProfileClient client.UserProfileClient walletClient client.WalletClient + walletDB *sql.DB registrationReward client.RegistrationRewardClient sevenDayCheckIn client.SevenDayCheckInClient } @@ -20,7 +24,9 @@ type Config struct { TaskClient client.TaskClient GrowthLevelClient client.GrowthLevelClient AchievementClient client.AchievementClient + UserProfileClient client.UserProfileClient WalletClient client.WalletClient + WalletDB *sql.DB RegistrationReward client.RegistrationRewardClient SevenDayCheckIn client.SevenDayCheckInClient } @@ -30,7 +36,9 @@ func New(config Config) *Handler { taskClient: config.TaskClient, growthLevelClient: config.GrowthLevelClient, achievementClient: config.AchievementClient, + userProfileClient: config.UserProfileClient, walletClient: config.WalletClient, + walletDB: config.WalletDB, registrationReward: config.RegistrationReward, sevenDayCheckIn: config.SevenDayCheckIn, } @@ -43,6 +51,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers { GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility, GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus, SignSevenDayCheckIn: h.signSevenDayCheckIn, + ListUserLeaderboards: h.listUserLeaderboards, } } diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go new file mode 100644 index 00000000..0ac21de7 --- /dev/null +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go @@ -0,0 +1,441 @@ +package activityapi + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "hyapp/pkg/appcode" + "hyapp/services/gateway-service/internal/auth" + "hyapp/services/gateway-service/internal/transport/http/httpkit" + + userv1 "hyapp.local/api/proto/user/v1" +) + +const ( + leaderboardTypeSent = "sent" + leaderboardTypeReceived = "received" + leaderboardTypeRoom = "room" + + leaderboardPeriodToday = "today" + leaderboardPeriodWeek = "week" + leaderboardPeriodMonth = "month" + + userLeaderboardMaxPageSize = 100 +) + +type userLeaderboardResponse struct { + Items []userLeaderboardItem `json:"items"` + Total int64 `json:"total"` + Page int32 `json:"page"` + PageSize int32 `json:"page_size"` + BoardType string `json:"board_type"` + Period string `json:"period"` + StartAtMS int64 `json:"start_at_ms"` + EndAtMS int64 `json:"end_at_ms"` + ServerTimeMS int64 `json:"server_time_ms"` + MyRank *userLeaderboardItem `json:"my_rank,omitempty"` +} + +type userLeaderboardItem struct { + Rank int64 `json:"rank"` + UserID string `json:"user_id,omitempty"` + RoomID string `json:"room_id,omitempty"` + GiftValue int64 `json:"gift_value"` + GiftCount int64 `json:"gift_count"` + TransactionCount int64 `json:"transaction_count"` + LastGiftAtMS int64 `json:"last_gift_at_ms"` + User *userLeaderboardUser `json:"user,omitempty"` + Room *userLeaderboardRoom `json:"room,omitempty"` +} + +type userLeaderboardUser struct { + UserID string `json:"user_id"` + DisplayUserID string `json:"display_user_id,omitempty"` + Username string `json:"username,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +type userLeaderboardRoom struct { + RoomID string `json:"room_id"` +} + +func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http.Request) { + if h.walletDB == nil { + httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error") + return + } + + boardType := normalizeUserLeaderboardType(request.URL.Query().Get("board_type")) + if boardType == "" { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + period := normalizeUserLeaderboardPeriod(request.URL.Query().Get("period")) + if period == "" { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + page, ok := httpkit.PositiveInt32Query(request, "page", 1) + if !ok { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) + if !ok { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + if pageSize > userLeaderboardMaxPageSize { + pageSize = userLeaderboardMaxPageSize + } + + now := time.Now().UTC() + start, end := userLeaderboardPeriodWindow(period, now) + startMS := start.UnixMilli() + endMS := end.UnixMilli() + app := appcode.FromContext(request.Context()) + total, err := countUserLeaderboard(request.Context(), h.walletDB, app, boardType, startMS, endMS) + if err != nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + items, err := listUserLeaderboardItems(request.Context(), h.walletDB, app, boardType, startMS, endMS, int(pageSize), int((page-1)*pageSize)) + if err != nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + + var myRank *userLeaderboardItem + viewerUserID := auth.UserIDFromContext(request.Context()) + if viewerUserID > 0 && boardType != leaderboardTypeRoom { + item, found, err := userLeaderboardRankForUser(request.Context(), h.walletDB, app, boardType, startMS, endMS, viewerUserID) + if err != nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + if found { + myRank = &item + } + } + if err := h.enrichUserLeaderboardItems(request, boardType, items, myRank); err != nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + + httpkit.WriteOK(writer, request, userLeaderboardResponse{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + BoardType: boardType, + Period: period, + StartAtMS: startMS, + EndAtMS: endMS, + ServerTimeMS: now.UnixMilli(), + MyRank: myRank, + }) +} + +func normalizeUserLeaderboardType(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "sent", "send", "sender", "gift_sent", "user_sent": + return leaderboardTypeSent + case "received", "receive", "receiver", "gift_received", "user_received": + return leaderboardTypeReceived + case "room", "rooms", "room_gift", "room_gifts": + return leaderboardTypeRoom + default: + return "" + } +} + +func normalizeUserLeaderboardPeriod(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "today", "day", "daily": + return leaderboardPeriodToday + case "week", "weekly": + return leaderboardPeriodWeek + case "month", "monthly": + return leaderboardPeriodMonth + default: + return "" + } +} + +func userLeaderboardPeriodWindow(period string, now time.Time) (time.Time, time.Time) { + utcNow := now.UTC() + dayStart := time.Date(utcNow.Year(), utcNow.Month(), utcNow.Day(), 0, 0, 0, 0, time.UTC) + switch period { + case leaderboardPeriodWeek: + weekday := int(dayStart.Weekday()) + if weekday == 0 { + weekday = 7 + } + return dayStart.AddDate(0, 0, 1-weekday), utcNow + case leaderboardPeriodMonth: + return time.Date(utcNow.Year(), utcNow.Month(), 1, 0, 0, 0, 0, time.UTC), utcNow + default: + return dayStart, utcNow + } +} + +func countUserLeaderboard(ctx context.Context, db *sql.DB, appCode string, boardType string, startMS int64, endMS int64) (int64, error) { + subject, predicate := userLeaderboardDimension(boardType) + query := fmt.Sprintf(` + WITH gift_facts AS ( + SELECT + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id, + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id, + COALESCE( + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), + 0 + ) AS gift_value, + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count, + created_at_ms + FROM wallet_transactions + WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded' + AND created_at_ms >= ? AND created_at_ms < ? + ), + aggregated AS ( + SELECT %s AS subject_id + FROM gift_facts + WHERE %s + GROUP BY %s + ) + SELECT COUNT(*) FROM aggregated`, subject, predicate, subject) + + var total int64 + err := db.QueryRowContext(ctx, query, appCode, startMS, endMS).Scan(&total) + return total, err +} + +func listUserLeaderboardItems(ctx context.Context, db *sql.DB, appCode string, boardType string, startMS int64, endMS int64, limit int, offset int) ([]userLeaderboardItem, error) { + subject, predicate := userLeaderboardDimension(boardType) + query := fmt.Sprintf(` + WITH gift_facts AS ( + SELECT + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id, + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id, + COALESCE( + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), + 0 + ) AS gift_value, + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count, + created_at_ms + FROM wallet_transactions + WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded' + AND created_at_ms >= ? AND created_at_ms < ? + ), + aggregated AS ( + SELECT %s AS subject_id, + SUM(gift_value) AS gift_value, + SUM(gift_count) AS gift_count, + COUNT(*) AS transaction_count, + MAX(created_at_ms) AS last_gift_at_ms + FROM gift_facts + WHERE %s + GROUP BY %s + ), + ranked AS ( + SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no, + subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms + FROM aggregated + ) + SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms + FROM ranked + ORDER BY rank_no ASC + LIMIT ? OFFSET ?`, subject, predicate, subject) + + rows, err := db.QueryContext(ctx, query, appCode, startMS, endMS, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]userLeaderboardItem, 0, limit) + for rows.Next() { + item, err := scanUserLeaderboardItem(rows, boardType) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func userLeaderboardRankForUser(ctx context.Context, db *sql.DB, appCode string, boardType string, startMS int64, endMS int64, userID int64) (userLeaderboardItem, bool, error) { + subject, predicate := userLeaderboardDimension(boardType) + query := fmt.Sprintf(` + WITH gift_facts AS ( + SELECT + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED) AS sender_user_id, + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) AS target_user_id, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')), '') AS room_id, + COALESCE( + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_added')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_point_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) AS SIGNED), + CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), + 0 + ) AS gift_value, + COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_count')) AS SIGNED), 0) AS gift_count, + created_at_ms + FROM wallet_transactions + WHERE app_code = ? AND biz_type = 'gift_debit' AND status = 'succeeded' + AND created_at_ms >= ? AND created_at_ms < ? + ), + aggregated AS ( + SELECT %s AS subject_id, + SUM(gift_value) AS gift_value, + SUM(gift_count) AS gift_count, + COUNT(*) AS transaction_count, + MAX(created_at_ms) AS last_gift_at_ms + FROM gift_facts + WHERE %s + GROUP BY %s + ), + ranked AS ( + SELECT ROW_NUMBER() OVER (ORDER BY gift_value DESC, last_gift_at_ms DESC, subject_id ASC) AS rank_no, + subject_id, gift_value, gift_count, transaction_count, last_gift_at_ms + FROM aggregated + ) + SELECT rank_no, CAST(subject_id AS CHAR), gift_value, gift_count, transaction_count, last_gift_at_ms + FROM ranked + WHERE subject_id = ? + LIMIT 1`, subject, predicate, subject) + + row := db.QueryRowContext(ctx, query, appCode, startMS, endMS, userID) + item, err := scanUserLeaderboardItem(row, boardType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return userLeaderboardItem{}, false, nil + } + return userLeaderboardItem{}, false, err + } + return item, true, nil +} + +type userLeaderboardScanner interface { + Scan(dest ...any) error +} + +func scanUserLeaderboardItem(scanner userLeaderboardScanner, boardType string) (userLeaderboardItem, error) { + var item userLeaderboardItem + var subject string + if err := scanner.Scan(&item.Rank, &subject, &item.GiftValue, &item.GiftCount, &item.TransactionCount, &item.LastGiftAtMS); err != nil { + return userLeaderboardItem{}, err + } + if boardType == leaderboardTypeRoom { + item.RoomID = subject + item.Room = &userLeaderboardRoom{RoomID: subject} + return item, nil + } + item.UserID = subject + item.User = &userLeaderboardUser{UserID: subject} + return item, nil +} + +func userLeaderboardDimension(boardType string) (string, string) { + switch boardType { + case leaderboardTypeReceived: + return "target_user_id", "target_user_id > 0" + case leaderboardTypeRoom: + return "room_id", "room_id <> ''" + default: + return "sender_user_id", "sender_user_id > 0" + } +} + +func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType string, items []userLeaderboardItem, myRank *userLeaderboardItem) error { + if boardType == leaderboardTypeRoom { + return nil + } + userIDs := make([]int64, 0, len(items)+1) + for _, item := range items { + if id, err := strconv.ParseInt(item.UserID, 10, 64); err == nil && id > 0 { + userIDs = append(userIDs, id) + } + } + if myRank != nil { + if id, err := strconv.ParseInt(myRank.UserID, 10, 64); err == nil && id > 0 { + userIDs = append(userIDs, id) + } + } + profiles, err := h.userLeaderboardProfiles(request, userIDs) + if err != nil { + return err + } + for i := range items { + applyUserLeaderboardProfile(&items[i], profiles) + } + if myRank != nil { + applyUserLeaderboardProfile(myRank, profiles) + } + return nil +} + +func (h *Handler) userLeaderboardProfiles(request *http.Request, userIDs []int64) (map[int64]*userv1.User, error) { + result := make(map[int64]*userv1.User) + if h.userProfileClient == nil || len(userIDs) == 0 { + return result, nil + } + userIDs = uniqueUserLeaderboardInt64s(userIDs) + resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{ + Meta: httpkit.UserMeta(request, ""), + UserIds: userIDs, + }) + if err != nil { + return nil, err + } + return resp.GetUsers(), nil +} + +func applyUserLeaderboardProfile(item *userLeaderboardItem, profiles map[int64]*userv1.User) { + if item == nil { + return + } + id, err := strconv.ParseInt(item.UserID, 10, 64) + if err != nil || id <= 0 { + return + } + profile := profiles[id] + if profile == nil { + return + } + item.User = &userLeaderboardUser{ + UserID: strconv.FormatInt(profile.GetUserId(), 10), + DisplayUserID: profile.GetDisplayUserId(), + Username: profile.GetUsername(), + Avatar: profile.GetAvatar(), + } +} + +func uniqueUserLeaderboardInt64s(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go new file mode 100644 index 00000000..a382de15 --- /dev/null +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go @@ -0,0 +1,40 @@ +package activityapi + +import ( + "testing" + "time" +) + +func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) { + now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60)) + + todayStart, todayEnd := userLeaderboardPeriodWindow(leaderboardPeriodToday, now) + if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) { + t.Fatalf("today start = %s", todayStart) + } + if !todayEnd.Equal(now.UTC()) { + t.Fatalf("today end = %s", todayEnd) + } + + weekStart, _ := userLeaderboardPeriodWindow(leaderboardPeriodWeek, now) + if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) { + t.Fatalf("week start = %s", weekStart) + } + + monthStart, _ := userLeaderboardPeriodWindow(leaderboardPeriodMonth, now) + if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) { + t.Fatalf("month start = %s", monthStart) + } +} + +func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) { + if normalizeUserLeaderboardType("receiver") != leaderboardTypeReceived { + t.Fatal("receiver should normalize to received") + } + if normalizeUserLeaderboardType("rooms") != leaderboardTypeRoom { + t.Fatal("rooms should normalize to room") + } + if normalizeUserLeaderboardPeriod("monthly") != leaderboardPeriodMonth { + t.Fatal("monthly should normalize to month") + } +} diff --git a/services/gateway-service/internal/transport/http/handler.go b/services/gateway-service/internal/transport/http/handler.go index 643d3b6e..2eb75a6c 100644 --- a/services/gateway-service/internal/transport/http/handler.go +++ b/services/gateway-service/internal/transport/http/handler.go @@ -2,6 +2,7 @@ package http import ( "context" + "database/sql" "hyapp/services/gateway-service/internal/transport/http/httpkit" "io" "net/http" @@ -42,6 +43,7 @@ type Handler struct { broadcastClient client.BroadcastClient gameClient client.GameClient appConfigReader appapi.ConfigReader + leaderboardWalletDB *sql.DB tencentIM TencentIMConfig tencentRTC TencentRTCConfig objectUploader ObjectUploader @@ -206,6 +208,11 @@ func (h *Handler) SetGameClient(gameClient client.GameClient) { h.gameClient = gameClient } +// SetLeaderboardWalletDB 注入 App 榜单只读钱包库连接。 +func (h *Handler) SetLeaderboardWalletDB(db *sql.DB) { + h.leaderboardWalletDB = db +} + // SetAppConfigReader 注入后台 App 配置读取依赖。 func (h *Handler) SetAppConfigReader(reader appapi.ConfigReader) { h.appConfigReader = reader diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 01d03c38..53dbe244 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -131,6 +131,7 @@ type TaskHandlers struct { GetRegistrationRewardEligibility http.HandlerFunc GetSevenDayCheckInStatus http.HandlerFunc SignSevenDayCheckIn http.HandlerFunc + ListUserLeaderboards http.HandlerFunc } type LevelHandlers struct { @@ -323,6 +324,7 @@ func (r routes) registerTaskRoutes() { r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility) r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus) r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn) + r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards) r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs) r.profile("/tasks/claim", http.MethodPost, h.ClaimTaskReward) } diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index d9e7f64c..b0eda327 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -60,7 +60,9 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { TaskClient: h.taskClient, GrowthLevelClient: h.growthLevelClient, AchievementClient: h.achievementClient, + UserProfileClient: h.userProfileClient, WalletClient: h.walletClient, + WalletDB: h.leaderboardWalletDB, RegistrationReward: h.registrationReward, SevenDayCheckIn: h.sevenDayCheckIn, })