574 lines
16 KiB
Go
574 lines
16 KiB
Go
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)
|
||
}
|