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