package userleaderboard import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/integration/roomclient" ) 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 room roomclient.Client } 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, room roomclient.Client) *Service { return &Service{walletDB: walletDB, userDB: userDB, room: room} } 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) // 排行榜价值优先使用送礼时按真实扣费比例生成的 heat_value;旧交易缺字段时只退回 coin_spent/charge_amount,不再读取 GIFT_POINT。 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, '$.heat_value')) AS SIGNED), CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) 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) // 排行榜列表和值排序统一使用 heat_value 口径,避免历史 gift_point 字段重新参与运营排行。 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, '$.heat_value')) AS SIGNED), CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) 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, '$.heat_value')) AS SIGNED), CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.coin_spent')) AS SIGNED), CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.charge_amount')) 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.room == nil || len(roomIDs) == 0 { return result, nil } roomIDs = uniqueStrings(roomIDs) _ = appCode for _, id := range roomIDs { room, err := s.room.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: id}) if err != nil { return nil, err } result[room.RoomID] = roomProfile{ RoomID: room.RoomID, RoomShortID: room.RoomShortID, Title: room.Title, CoverURL: room.CoverURL, OwnerUserID: room.OwnerUserID, Status: room.Status, } } return result, nil } 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) }