2026-05-25 15:45:57 +08:00

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