574 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}