灵仙和小游游戏

This commit is contained in:
zhx 2026-05-20 18:37:44 +08:00
parent 61f4e31b6a
commit 21dd835c75
26 changed files with 1482 additions and 81 deletions

View File

@ -51,6 +51,7 @@ import (
searchmodule "hyapp-admin-server/internal/modules/search"
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
uploadmodule "hyapp-admin-server/internal/modules/upload"
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/platform/tencentcos"
"hyapp-admin-server/internal/repository"
@ -237,6 +238,7 @@ func main() {
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomDB),
}
engine := router.New(cfg, auth, handlers)

View File

@ -3,18 +3,21 @@ package gamemanagement
import gamev1 "hyapp.local/api/proto/game/v1"
type platformDTO struct {
AppCode string `json:"appCode"`
PlatformCode string `json:"platformCode"`
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
AdapterType string `json:"adapterType"`
AppCode string `json:"appCode"`
PlatformCode string `json:"platformCode"`
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
// adapterType 决定服务端按哪家厂商协议拼启动 URL 和处理回调。
AdapterType string `json:"adapterType"`
// callbackSecretSet 只告诉后台“已配置”,不把密钥明文下发到浏览器。
CallbackSecretSet bool `json:"callbackSecretSet"`
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
AdapterConfigJSON string `json:"adapterConfigJson"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
// adapterConfigJson 承载 uid_mode/default_lang/token_ttl_seconds 等可热更新配置。
AdapterConfigJSON string `json:"adapterConfigJson"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type catalogDTO struct {

View File

@ -12,15 +12,18 @@ import (
)
type platformRequest struct {
PlatformCode string `json:"platformCode"`
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
AdapterType string `json:"adapterType"`
PlatformCode string `json:"platformCode"`
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
// AdapterType 只允许服务端白名单中的值,避免后台误填导致回调走错协议。
AdapterType string `json:"adapterType"`
// CallbackSecret 只有提交非空时才覆盖旧密钥,编辑其它字段时前端传空即可。
CallbackSecret string `json:"callbackSecret"`
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
AdapterConfigJSON string `json:"adapterConfigJson"`
SortOrder int32 `json:"sortOrder"`
// AdapterConfigJSON 是厂商扩展配置,保持 JSON 字符串透传给 game-service。
AdapterConfigJSON string `json:"adapterConfigJson"`
SortOrder int32 `json:"sortOrder"`
}
type catalogRequest struct {

View File

@ -0,0 +1,87 @@
package userleaderboard
import (
"database/sql"
"errors"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(walletDB *sql.DB, userDB *sql.DB, roomDB *sql.DB) *Handler {
return &Handler{service: NewService(walletDB, userDB, roomDB)}
}
func (h *Handler) List(c *gin.Context) {
query, ok := parseQuery(c)
if !ok {
return
}
result, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
if err != nil {
if errors.Is(err, errInvalidArgument) {
response.BadRequest(c, "榜单参数不正确")
return
}
response.ServerError(c, "获取用户榜单失败")
return
}
response.OK(c, result)
}
func parseQuery(c *gin.Context) (Query, bool) {
options := shared.ListOptions(c)
boardType := NormalizeBoardType(firstQuery(c, "board_type", "boardType", "type"))
if boardType == "" {
response.BadRequest(c, "榜单类型不正确")
return Query{}, false
}
period := NormalizePeriod(firstQuery(c, "period", "time_dimension", "timeDimension"))
if period == "" {
response.BadRequest(c, "时间维度不正确")
return Query{}, false
}
viewerUserID, ok := optionalInt64(firstQuery(c, "viewer_user_id", "viewerUserId", "user_id", "userId"))
if !ok {
response.BadRequest(c, "用户 ID 不正确")
return Query{}, false
}
return NormalizeQuery(Query{
BoardType: boardType,
Period: period,
Page: options.Page,
PageSize: options.PageSize,
ViewerUserID: viewerUserID,
}), true
}
func optionalInt64(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
return value, err == nil && value >= 0
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,15 @@
package userleaderboard
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/activity/user-leaderboards", middleware.RequirePermission("user-leaderboard:view"), h.List)
}

View File

@ -0,0 +1,578 @@
package userleaderboard
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
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
roomDB *sql.DB
}
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, roomDB *sql.DB) *Service {
return &Service{walletDB: walletDB, userDB: userDB, roomDB: roomDB}
}
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.roomDB == nil || len(roomIDs) == 0 {
return result, nil
}
roomIDs = uniqueStrings(roomIDs)
args := make([]any, 0, len(roomIDs)+1)
args = append(args, appCode)
for _, id := range roomIDs {
args = append(args, id)
}
rows, err := s.roomDB.QueryContext(ctx, fmt.Sprintf(`
SELECT room_id, room_short_id, COALESCE(title, ''), COALESCE(cover_url, ''), owner_user_id, status
FROM room_list_entries
WHERE app_code = ? AND room_id IN (%s)
`, placeholders(len(roomIDs))), args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var profile roomProfile
if err := rows.Scan(&profile.RoomID, &profile.RoomShortID, &profile.Title, &profile.CoverURL, &profile.OwnerUserID, &profile.Status); err != nil {
return nil, err
}
result[profile.RoomID] = profile
}
return result, rows.Err()
}
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)
}

View File

@ -0,0 +1,38 @@
package userleaderboard
import (
"testing"
"time"
)
func TestPeriodWindowUsesUTCBoundaries(t *testing.T) {
now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60))
todayStart, todayEnd := periodWindow(PeriodToday, now)
if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) {
t.Fatalf("today start = %s", todayStart)
}
if !todayEnd.Equal(now.UTC()) {
t.Fatalf("today end = %s", todayEnd)
}
weekStart, _ := periodWindow(PeriodWeek, now)
if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) {
t.Fatalf("week start = %s", weekStart)
}
monthStart, _ := periodWindow(PeriodMonth, now)
if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) {
t.Fatalf("month start = %s", monthStart)
}
}
func TestNormalizeQueryDefaultsAndCapsPageSize(t *testing.T) {
query := NormalizeQuery(Query{BoardType: "receiver", Period: "monthly", PageSize: 500})
if query.BoardType != BoardTypeReceived || query.Period != PeriodMonth {
t.Fatalf("query = %+v", query)
}
if query.Page != 1 || query.PageSize != maxPageSize {
t.Fatalf("pagination = page %d pageSize %d", query.Page, query.PageSize)
}
}

View File

@ -98,6 +98,7 @@ var defaultPermissions = []model.Permission{
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
{Name: "房间宝箱查看", Code: "room-treasure:view", Kind: "menu"},
{Name: "房间宝箱更新", Code: "room-treasure:update", Kind: "button"},
{Name: "用户榜单查看", Code: "user-leaderboard:view", Kind: "menu"},
{Name: "角色查看", Code: "role:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"},
{Name: "角色更新", Code: "role:update", Kind: "button"},
@ -233,6 +234,7 @@ func (s *Store) seedMenus() error {
{ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true},
{ParentID: &activityID, Title: "七日签到", Code: "seven-day-checkin", Path: "/activities/seven-day-checkin", Icon: "event_available", PermissionCode: "seven-day-checkin:view", Sort: 72, Visible: true},
{ParentID: &activityID, Title: "房间宝箱", Code: "room-treasure", Path: "/activities/room-treasure", Icon: "redeem", PermissionCode: "room-treasure:view", Sort: 73, Visible: true},
{ParentID: &activityID, Title: "用户榜单", Code: "user-leaderboard", Path: "/activities/user-leaderboards", Icon: "leaderboard", PermissionCode: "user-leaderboard:view", Sort: 74, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},

View File

@ -31,6 +31,7 @@ import (
"hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/sevendaycheckin"
"hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/modules/userleaderboard"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/service"
@ -66,6 +67,7 @@ type Handlers struct {
Search *search.Handler
SevenDayCheckIn *sevendaycheckin.Handler
Upload *upload.Handler
UserLeaderboard *userleaderboard.Handler
}
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
@ -106,6 +108,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
upload.RegisterRoutes(protected, h.Upload)
search.RegisterRoutes(protected, h.Search)
sevendaycheckin.RegisterRoutes(protected, h.SevenDayCheckIn)
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
return engine
}

View File

@ -29,19 +29,24 @@ const (
// Platform 是第三方游戏平台配置事实。
type Platform struct {
AppCode string
PlatformCode string
PlatformName string
Status string
APIBaseURL string
AdapterType string
AppCode string
PlatformCode string
PlatformName string
Status string
// APIBaseURL 是厂商 H5/Auth 入口,按环境在后台改,不放服务配置文件。
APIBaseURL string
// AdapterType 决定启动参数、签名/解密规则、回包结构和错误码映射。
AdapterType string
// CallbackSecretCiphertext 存放厂商 key/AppSecret接口响应不回显明文。
CallbackSecretCiphertext string
CallbackSecretConfigured bool
CallbackIPWhitelist []string
AdapterConfigJSON string
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
// CallbackIPWhitelist 为空表示不限制;配置后支持单 IP 或 CIDR。
CallbackIPWhitelist []string
// AdapterConfigJSON 存放 uid_mode/default_lang/token_ttl_seconds 等厂商扩展项。
AdapterConfigJSON string
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
// CatalogItem 是内部稳定 game_id 到第三方 provider_game_id 的映射。
@ -84,8 +89,9 @@ type AppGame struct {
// LaunchableGame 汇总启动时需要的平台和目录配置。
type LaunchableGame struct {
CatalogItem
PlatformName string
PlatformStatus string
PlatformName string
PlatformStatus string
// 以下平台字段随 catalog 一起取出,启动链路不再额外查一次平台配置。
APIBaseURL string
AdapterType string
CallbackSecretCiphertext string
@ -95,14 +101,16 @@ type LaunchableGame struct {
// LaunchSession 是 H5 启动会话持久事实token 只保存 hash。
type LaunchSession struct {
AppCode string
SessionID string
UserID int64
DisplayUserID string
RoomID string
Scene string
PlatformCode string
GameID string
AppCode string
SessionID string
UserID int64
// DisplayUserID 用于对外 uid_mode避免直接暴露内部数字 ID。
DisplayUserID string
RoomID string
Scene string
PlatformCode string
GameID string
// ProviderGameID 是厂商侧 gameId/gameUid回调时用它防止跨游戏复用 token。
ProviderGameID string
LaunchTokenHash string
Status string
@ -113,15 +121,18 @@ type LaunchSession struct {
// GameOrder 是 provider_order_id 对应的钱包改账事实。
type GameOrder struct {
AppCode string
OrderID string
PlatformCode string
ProviderOrderID string
ProviderRoundID string
GameID string
ProviderGameID string
UserID int64
RoomID string
AppCode string
OrderID string
PlatformCode string
// ProviderOrderID 是厂商幂等键,唯一索引用它挡住重复扣款/返奖。
ProviderOrderID string
// ProviderRoundID 用于关联同一局的投注和返奖。
ProviderRoundID string
GameID string
ProviderGameID string
UserID int64
RoomID string
// OpType 只允许钱包语义 debit/credit厂商枚举在适配器层转换。
OpType string
CoinAmount int64
Status string
@ -162,20 +173,24 @@ type CallbackLog struct {
Operation string
RequestID string
ProviderRequestID string
SignatureValid bool
RequestHash string
ResponseCode string
ResponseBodyHash string
Status string
CreatedAtMS int64
// SignatureValid 用来区分验签失败、业务失败和系统失败,便于联调排错。
SignatureValid bool
// RequestHash/ResponseBodyHash 避免把 token、签名、用户资料等敏感原文直接进日志表。
RequestHash string
ResponseCode string
ResponseBodyHash string
Status string
CreatedAtMS int64
}
// RepairOrder 是三方补单请求的最小审计事实;首版可只落库不改账。
type RepairOrder struct {
AppCode string
RepairID string
PlatformCode string
AppCode string
RepairID string
PlatformCode string
// ProviderOrderID 唯一约束保证重复补单只产生一条审计事实。
ProviderOrderID string
// Reason 标记来源厂商和接口,后续补偿任务可以按 reason 选择不同策略。
Reason string
Status string
OperatorAdminID int64

View File

@ -15,6 +15,7 @@ import (
)
const (
// 灵仙接口使用 errorCode 作为业务结果HTTP 成功不代表订单成功。
leaderccCodeOK = 0
leaderccCodeTimeout = 4000
leaderccCodeInsufficientBalance = 4004
@ -27,13 +28,17 @@ const (
leaderccDefaultTokenTTL = 24 * time.Hour
)
// leaderccAdapterConfig 对应后台平台配置里的 adapter_config_json。
// 目前只放跨环境可调整字段,避免每接一个灵仙游戏或切域名都发版。
type leaderccAdapterConfig struct {
// uid_mode 必须和启动 URL、userinfo、change_coin 三处保持一致。
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
}
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
// Platform 用于回调LaunchableGame 用于启动;两处共用同一套 JSON 解析规则。
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
@ -49,6 +54,7 @@ func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
}
func (c leaderccAdapterConfig) TokenTTL() time.Duration {
// 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
@ -56,11 +62,13 @@ func (c leaderccAdapterConfig) TokenTTL() time.Duration {
}
func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
// 灵仙是明文 JSON + MD5 签名,所以先做 IP 白名单,再进入具体接口校验签名。
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
raw, contentType := leaderccJSON(leaderccCodeRiskRejected, "ip forbidden", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeRiskRejected), false, "", nil
}
switch operation {
// operation 由统一 callback 路由传入,兼容不同命名可以减少网关层特殊分支。
case "userinfo", "user_info", "get_user_info":
return s.handleLeaderCCUserInfo(ctx, app, platform, req)
case "change_coin", "change_balance", "update_coin":
@ -74,6 +82,7 @@ func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platf
}
func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
// userinfo 签名顺序来自灵仙文档gameId + uid + token + roomId + key。
var body struct {
GameID string `json:"gameId"`
UID string `json:"uid"`
@ -84,11 +93,13 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
// key 存在 callback_secret 字段里;后台不回显明文,更新时才覆盖。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.GameID, body.UID, body.Token, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, "")
}
config := leaderccConfigFromPlatform(platform)
session, err := s.validLeaderCCSession(ctx, app, body.Token)
// token 通过后仍要校验 uid/gameId防止合法 token 被跨玩家或跨游戏复用。
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "")
}
@ -97,6 +108,7 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo
return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, "")
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{
// 灵仙字段名是 coin这里直接返回钱包可用 COIN避免在适配器层引入换算。
"uid": body.UID,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
@ -106,6 +118,7 @@ func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platfo
}
func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) {
// change_coin 是灵仙唯一改账入口orderId 是幂等键roundId 用来关联同一轮投注/返奖。
var body struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
@ -123,20 +136,25 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
// 文档签名顺序orderId + gameId + roundId + uid + coin + type + rewardType + token + winId + roomId + key。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.Type), 10), strconv.FormatInt(int64(body.RewardType), 10), body.Token, body.WinID, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID)
}
config := leaderccConfigFromPlatform(platform)
session, err := s.validLeaderCCSession(ctx, app, body.Token)
// 灵仙 change_coin 既带 token 又带 uid/gameId三者都必须命中同一条启动会话。
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
opType := leaderccOpType(body.Type)
// type=1 扣款、type=2 加款coin=0 没有实际改账,但仍会按幂等订单记成功。
if providerOrderID == "" || opType == "" || body.Coin < 0 {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID)
}
// 钱包改账和幂等逻辑与 Yomi 一致,厂商差异只停留在签名和字段映射层。
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.RoundID, opType, body.Coin, body.RoomID, requestHash)
if err != nil {
// 灵仙余额不足需要返回 4004其它内部错误在 leaderccCodeFromError 里统一映射。
code := leaderccCodeFromError(err)
message := leaderccMessageFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
@ -148,6 +166,7 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat
}
func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
// 补单接口必须提供给厂商,但当前阶段只记录,不自动加钱,避免绕过钱包订单幂等。
var body struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
@ -163,12 +182,14 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
// 文档签名顺序orderId + gameId + roundId + uid + coin + rewardType + winId + roomId + key。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.RewardType), 10), body.WinID, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID)
}
if providerOrderID == "" || strings.TrimSpace(body.GameID) == "" || strings.TrimSpace(body.UID) == "" || body.Coin < 0 {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID)
}
// repair_id 稳定派生,厂商重复补单会命中唯一索引并保持幂等。
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
AppCode: app,
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID),
@ -185,6 +206,7 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla
}
func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
// 灵仙和 Yomi 都复用 game_launch_sessions差异在字段名不在 token 存储方式。
session, err := s.validYomiSession(ctx, app, token)
if err != nil {
return gamedomain.LaunchSession{}, err
@ -193,6 +215,7 @@ func (s *Service) validLeaderCCSession(ctx context.Context, app string, token st
}
func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAdapterConfig, uid string, gameID string) bool {
// gameId 对应内部目录里的 provider_game_iduid 取值由 uid_mode 决定。
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
@ -200,6 +223,7 @@ func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAda
}
func leaderccOpType(value int32) string {
// 厂商枚举在适配器内结束,下游钱包只认识 debit/credit。
switch value {
case 1:
return "debit"
@ -211,18 +235,21 @@ func leaderccOpType(value int32) string {
}
func decodeLeaderCCJSON(raw []byte, out any) error {
// UseNumber 保持大订单号/局号不被 JSON float64 解析损坏,虽然当前结构体多数字段是 string。
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
return decoder.Decode(out)
}
func leaderccSignValid(actual string, key string, parts ...string) bool {
// 灵仙签名是“按文档字段顺序拼接所有字段 + key”后取 MD5 小写 hex。
key = strings.TrimSpace(key)
if key == "" {
return false
}
joined := strings.Builder{}
for _, part := range parts {
// 厂商样例没有分隔符;这里 trim 只清理传输空白,不改变字段顺序。
joined.WriteString(strings.TrimSpace(part))
}
joined.WriteString(key)
@ -232,11 +259,13 @@ func leaderccSignValid(actual string, key string, parts ...string) bool {
}
func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
// signatureValid/providerRequestID 会写入 callback log帮助后续排查签名和重复订单问题。
raw, contentType := leaderccJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}
func leaderccJSON(errorCode int, message string, data any) ([]byte, string) {
// 灵仙成功可以不带 message失败时带 message 方便联调定位。
payload := map[string]any{"errorCode": errorCode}
if data != nil {
payload["data"] = data
@ -248,6 +277,7 @@ func leaderccJSON(errorCode int, message string, data any) ([]byte, string) {
}
func leaderccCodeFromError(err error) int {
// 内部错误码只在适配器边界映射为灵仙错误码,业务层不依赖厂商语义。
switch {
case err == nil:
return leaderccCodeOK

View File

@ -185,6 +185,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := "gt_" + randomHex(24)
sessionTTL := s.config.LaunchSessionTTL
// 三方游戏会在 H5 运行期间持续回调 token厂商适配器可以把默认 15 分钟启动 TTL 拉长。
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
@ -192,15 +193,16 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
}
expiresAt := now.Add(sessionTTL).UnixMilli()
session := gamedomain.LaunchSession{
AppCode: command.AppCode,
SessionID: sessionID,
UserID: command.UserID,
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
Scene: command.Scene,
PlatformCode: game.PlatformCode,
GameID: game.GameID,
ProviderGameID: game.ProviderGameID,
AppCode: command.AppCode,
SessionID: sessionID,
UserID: command.UserID,
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
Scene: command.Scene,
PlatformCode: game.PlatformCode,
GameID: game.GameID,
ProviderGameID: game.ProviderGameID,
// 明文 token 只返回给客户端和厂商,库里只保存 hash回调时通过 hash 查 session。
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: expiresAt,
@ -235,6 +237,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
operation := strings.ToLower(strings.TrimSpace(req.GetOperation()))
// requestHash 是回调原文摘要,用于审计和钱包幂等校验,避免落库保存完整敏感 body。
requestHash := stableHashBytes(req.GetRawBody())
callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano()))
@ -245,6 +248,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
var statusCode string
var err error
if platform, platformErr := s.repository.GetPlatform(ctx, app, req.GetPlatformCode()); platformErr == nil {
// adapter_type 是三方协议边界:新增厂商时只新增适配器,不改钱包/用户主流程。
switch {
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterYomiV4):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash)
@ -254,6 +258,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
}
} else {
// 找不到平台时退回 demo保留旧测试入口行为避免管理后台未配置时直接打断联调。
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
}
responseHash := stableHashBytes(responseBody)
@ -278,6 +283,7 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
Status: logStatus,
CreatedAtMS: s.now().UnixMilli(),
})
// InsertCallbackLog 失败不影响厂商响应;回调主链路优先保证游戏侧不超时。
return responseBody, contentType, err
}
@ -537,6 +543,7 @@ func normalizeScene(scene string) string {
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
base := strings.TrimSpace(game.APIBaseURL)
if base == "" {
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
base = "https://game.example.local/h5"
}
parsed, err := url.Parse(base)
@ -545,6 +552,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
}
query := parsed.Query()
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
// Yomi 的启动地址是 auth/gateway用户合法性通过 platAuthCode 在服务端 gettoken 二次校验。
config := yomiConfigFromPlatform(game)
lang := config.DefaultLang
if lang == "" {
@ -554,6 +562,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
query.Set("platAuthCode", token)
query.Set("platUserId", externalUserID(session, config.UIDMode))
query.Set("lang", lang)
// Yomi gameLevelUid 语义类似房间/场次,没有后台配置时必须显式传 0。
if config.GameLevelUID > 0 {
query.Set("gameLevelUid", strconv.FormatInt(config.GameLevelUID, 10))
} else {
@ -566,6 +575,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
// 灵仙启动只需要 uid/token/lang/roomid后续 userinfo/change_coin 通过 token + MD5 签名校验。
config := leaderccConfigFromPlatform(game)
lang := config.DefaultLang
if lang == "" {
@ -580,6 +590,7 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
query.Set("session_id", session.SessionID)
query.Set("session_token", token)
query.Set("game_id", session.GameID)

View File

@ -19,6 +19,7 @@ import (
)
const (
// Yomi 返回码必须按厂商文档透传,游戏侧会直接根据这些数字决定是否重试或弹错。
yomiCodeOK = 200
yomiCodeSystemError = 9998
yomiCodeInsufficientBalance = 10003
@ -32,14 +33,20 @@ const (
yomiDefaultTokenTTL = 24 * time.Hour
)
// yomiAdapterConfig 是后台 adapter_config_json 的结构化视图。
// 这类厂商字段必须放在 DB 配置里,后续切测试/正式环境或新增游戏档位无需重启服务。
type yomiAdapterConfig struct {
// uid_mode 控制给厂商的用户标识来源:
// 空或 user_id 使用内部数字 user_iddisplay_user_id 使用 App 展示 ID。
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
GameLevelUID int64 `json:"game_level_uid"`
// game_level_uid 对应 Yomi 房间/场次,没有配置时按文档传 0。
GameLevelUID int64 `json:"game_level_uid"`
}
func yomiConfigFromPlatform(value any) yomiAdapterConfig {
// 启动链路拿到的是 LaunchableGame回调链路拿到的是 Platform这里统一解析避免两处配置规则漂移。
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
@ -55,6 +62,7 @@ func yomiConfigFromPlatform(value any) yomiAdapterConfig {
}
func (c yomiAdapterConfig) TokenTTL() time.Duration {
// Yomi 文档建议 token 至少 24 小时;后台可以调大或调小,但空配置必须有安全默认值。
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
@ -62,6 +70,7 @@ func (c yomiAdapterConfig) TokenTTL() time.Duration {
}
func externalUserID(session gamedomain.LaunchSession, uidMode string) string {
// 三方通常会把 uid 展示给玩家或写入订单,切换 uid_mode 后必须保持启动和回调校验一致。
if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" {
return strings.TrimSpace(session.DisplayUserID)
}
@ -69,10 +78,12 @@ func externalUserID(session gamedomain.LaunchSession, uidMode string) string {
}
func (s *Service) handleYomiOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
// IP 白名单先于解密执行,可以挡住明显非厂商来源的请求,减少密钥探测面。
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{})
return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil
}
// Yomi 请求体是 JSON -> AES-GCM -> base64 URL 编码body 解不开就按签名错误返回。
body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext)
if err != nil {
raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{})
@ -80,6 +91,7 @@ func (s *Service) handleYomiOperation(ctx context.Context, app string, platform
}
switch operation {
// operation 来自网关路由尾段,不同部署可能保留 api/ 前缀,所以兼容常见写法。
case "gettoken", "get_token", "api/gettoken":
return s.handleYomiGetToken(ctx, app, platform, req, body)
case "userinfo", "user_info", "api/userinfo":
@ -95,6 +107,8 @@ func (s *Service) handleYomiOperation(ctx context.Context, app string, platform
}
func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
// gettoken 是 Yomi 用启动链接里的 platAuthCode 换平台 token 的闭环校验。
// 当前实现直接复用启动 tokenYomi 后续 userinfo/change_balance 都必须带同一个 platToken。
var body struct {
GameUID json.Number `json:"gameUid"`
Lang string `json:"lang"`
@ -108,6 +122,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g
}
config := yomiConfigFromPlatform(platform)
session, err := s.validYomiSession(ctx, app, body.PlatAuthCode)
// 同时校验 token、用户和游戏避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
}
@ -116,6 +131,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
}
data := map[string]any{
// platUserId 必须原样返回Yomi 文档明确要求保持业务含义稳定。
"platUserId": body.PlatUserID,
"platUserOpenId": session.DisplayUserID,
"nickname": snapshot.Nickname,
@ -129,6 +145,7 @@ func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform g
}
func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
// userinfo 是游戏侧进入房间或中途刷新玩家资料时调用,只读用户资料和当前金币。
var body struct {
PlatUserID string `json:"platUserId"`
PlatToken string `json:"platToken"`
@ -140,6 +157,7 @@ func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform g
}
config := yomiConfigFromPlatform(platform)
session, err := s.validYomiSession(ctx, app, body.PlatToken)
// userinfo 没有 gameUid 时只校验用户change_balance 会额外校验游戏 ID。
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") {
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
}
@ -159,6 +177,7 @@ func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform g
}
func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) {
// change_balance 是唯一真正改钱包的 Yomi 回调requestId 是厂商幂等键,必须落库去重。
var body struct {
RequestID json.Number `json:"requestId"`
PlatUserID string `json:"platUserId"`
@ -179,15 +198,18 @@ func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platf
providerOrderID := strings.TrimSpace(body.RequestID.String())
config := yomiConfigFromPlatform(platform)
session, err := s.validYomiSession(ctx, app, body.PlatToken)
// 改账必须绑定 token、uid、gameUid 三个维度,防止跨用户、跨游戏复用合法 token。
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID)
}
opType := yomiOpType(body.ReasonType)
// reasonType=1 扣款reasonType=2 加款gold=0 是免费局/未中奖的合法结算。
if providerOrderID == "" || opType == "" || body.Gold < 0 {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
}
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash)
if err != nil {
// 厂商要求失败也返回当前余额,查不到时 bestEffortCoinBalance 会退化为 0。
code := yomiCodeFromError(err)
message := yomiMessageFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
@ -199,6 +221,7 @@ func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platf
}
func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) {
// 补单接口首版只做简单校验和审计落库,不直接补钱,符合当前“先写 Log”边界。
var body struct {
RequestID json.Number `json:"requestId"`
OrderID string `json:"orderId"`
@ -214,6 +237,7 @@ func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platfor
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
// 不同厂商/版本可能只给 requestId这里把 orderId 和 requestId 都兼容成 providerOrderID。
if providerOrderID == "" {
providerOrderID = strings.TrimSpace(body.RequestID.String())
}
@ -240,6 +264,7 @@ type yomiCoinChangeResult struct {
}
func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) {
// order_id 用平台订单号稳定派生,保证厂商重试、服务重启、并发请求都命中同一条订单。
order := gamedomain.GameOrder{
AppCode: app,
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID),
@ -260,9 +285,11 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
return yomiCoinChangeResult{}, err
}
if exists && order.Status == gamedomain.OrderStatusSucceeded {
// 已成功订单按幂等重放处理,直接返回历史余额,不能再次调用钱包。
return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter}, nil
}
if amount == 0 {
// Yomi 文档允许投注/返奖金额为 0这类订单仍需标记成功但不能产生钱包流水。
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil {
return yomiCoinChangeResult{}, err
@ -272,6 +299,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
if s.wallet == nil {
return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
// wallet-service 持有真实余额game-service 只传入平台订单和幂等命令 ID。
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
@ -290,6 +318,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
if err != nil {
status := gamedomain.OrderStatusFailed
if xerr.IsCode(err, xerr.IdempotencyConflict) {
// 钱包侧发现幂等冲突时单独标记,便于后续运营排查重复或参数不一致订单。
status = gamedomain.OrderStatusConflict
}
_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
@ -302,12 +331,14 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
}
type yomiUserSnapshot struct {
// 厂商只需要展示资料和金币,不把内部用户完整模型暴露给适配器。
Nickname string
Avatar string
Balance int64
}
func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) {
// 用户资料走 user-service金币走 wallet-service避免 game-service 缓存造成余额不准。
if s.user == nil || s.wallet == nil {
return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
}
@ -331,6 +362,7 @@ func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.
}
func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 {
// 余额查询失败时不阻断错误响应;厂商协议更需要一个可解析的 balance 字段。
if s.wallet == nil || userID <= 0 {
return 0
}
@ -352,6 +384,7 @@ func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, request
}
func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
// 启动 token 明文只出现在启动 URL 和厂商回调里,数据库里按 hash 查询。
if strings.TrimSpace(token) == "" {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
@ -366,6 +399,7 @@ func (s *Service) validYomiSession(ctx context.Context, app string, token string
}
func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool {
// gameUID 允许为空是为了兼容 userinfo只要厂商传了就必须和启动会话里的 provider_game_id 一致。
if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
@ -374,6 +408,7 @@ func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConf
}
func yomiOpType(reasonType int32) string {
// 转成钱包服务理解的 debit/credit厂商枚举不向下游扩散。
switch reasonType {
case 1:
return "debit"
@ -385,12 +420,14 @@ func yomiOpType(reasonType int32) string {
}
func decodeYomiJSON(raw []byte, out any) error {
// UseNumber 避免 requestId/gameRoundId 这类 64 位数字经过 float64 丢精度。
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
return decoder.Decode(out)
}
func decryptYomiBody(raw []byte, secret string) ([]byte, error) {
// 文档给的 AppSecret 是 32 字节字符串,正好对应 AES-256 key。
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty")
@ -410,12 +447,14 @@ func decryptYomiBody(raw []byte, secret string) ([]byte, error) {
if len(decoded) <= gcm.NonceSize() {
return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short")
}
// Yomi 把 IV/nonce 放在密文前 12 位,剩余部分是 GCM ciphertext+tag。
nonce := decoded[:gcm.NonceSize()]
ciphertext := decoded[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func decodeYomiBase64(value string) ([]byte, error) {
// 文档写 URL base64但测试工具经常混用 padded/raw/std这里按宽松解析提升联调成功率。
encodings := []*base64.Encoding{
base64.URLEncoding,
base64.RawURLEncoding,
@ -434,6 +473,7 @@ func decodeYomiBase64(value string) ([]byte, error) {
}
func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool {
// 空白名单表示不限制;配置后支持单 IP 和 CIDR便于同时兼容测试环境和正式机房。
if len(whitelist) == 0 {
return true
}
@ -457,6 +497,7 @@ func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool {
}
func callbackRequestIP(req *gamev1.CallbackRequest) string {
// 网关会把真实来源 IP 写在转发头里;优先取第一段 X-Forwarded-For。
for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} {
if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
return strings.TrimSpace(strings.Split(raw, ",")[0])
@ -475,11 +516,13 @@ func callbackRequestIP(req *gamev1.CallbackRequest) string {
}
func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
// 返回值同时服务“HTTP 响应”和“回调日志”,所以这里保留签名状态和平台请求号。
raw, contentType := yomiJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}
func yomiJSON(code int, message string, data any) ([]byte, string) {
// Yomi 成功 message 固定 success失败 message 给具体错误原因data 必须始终是对象。
if strings.TrimSpace(message) == "" {
message = "success"
}
@ -490,6 +533,7 @@ func yomiJSON(code int, message string, data any) ([]byte, string) {
}
func yomiCodeFromError(err error) int {
// 内部错误码在适配器边界转换成厂商错误码,避免污染业务服务的错误模型。
switch {
case err == nil:
return yomiCodeOK
@ -523,6 +567,7 @@ func yomiMessageFromError(err error) string {
}
func firstNonEmpty(values ...string) string {
// roomId 这类字段优先使用回调里的实时值,缺失时回退启动会话里的值。
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)

View File

@ -312,6 +312,7 @@ func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQ
}
func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) {
// 启动游戏时一次性取出 catalog + platform 配置,避免拿到游戏后再查平台导致配置不一致。
row := r.db.QueryRowContext(ctx,
`SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category,
c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.min_coin, c.status,
@ -339,6 +340,7 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game
}
func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) {
// 回调入口只知道 platform_code必须按 app_code 隔离查询,防止多租户平台编码冲突。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'),
callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
@ -701,6 +703,7 @@ func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Pla
if adapterConfig == "" {
adapterConfig = "{}"
}
// callback_secret_ciphertext 空值表示“保持旧密钥”,这样后台编辑 URL/白名单时不会误清密钥。
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (
app_code, platform_code, platform_name, status, api_base_url, adapter_type,
@ -839,6 +842,7 @@ func scanPlatform(scanner catalogScanner) (gamedomain.Platform, error) {
if err := scanner.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return gamedomain.Platform{}, err
}
// 只把“是否已配置”暴露给上层,密钥明文不再通过 DTO/Proto 回给后台。
item.CallbackSecretConfigured = strings.TrimSpace(item.CallbackSecretCiphertext) != ""
_ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist)
return item, nil

View File

@ -257,15 +257,16 @@ func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame {
func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
return &gamev1.GamePlatform{
AppCode: item.AppCode,
PlatformCode: item.PlatformCode,
PlatformName: item.PlatformName,
Status: item.Status,
ApiBaseUrl: item.APIBaseURL,
SortOrder: item.SortOrder,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
AdapterType: item.AdapterType,
AppCode: item.AppCode,
PlatformCode: item.PlatformCode,
PlatformName: item.PlatformName,
Status: item.Status,
ApiBaseUrl: item.APIBaseURL,
SortOrder: item.SortOrder,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
AdapterType: item.AdapterType,
// callback_secret 明文只允许写入,不允许通过列表/详情接口回显到后台前端。
CallbackSecretSet: item.CallbackSecretConfigured,
CallbackIpWhitelist: item.CallbackIPWhitelist,
AdapterConfigJson: item.AdapterConfigJSON,
@ -277,12 +278,13 @@ func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform {
return gamedomain.Platform{}
}
return gamedomain.Platform{
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
AdapterType: item.GetAdapterType(),
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
AdapterType: item.GetAdapterType(),
// 更新接口只有在 callback_secret 非空时才会覆盖旧密钥,空值表示保留原密钥。
CallbackSecretCiphertext: item.GetCallbackSecret(),
CallbackSecretConfigured: item.GetCallbackSecretSet(),
CallbackIPWhitelist: item.GetCallbackIpWhitelist(),
@ -335,6 +337,7 @@ func catalogFromProto(item *gamev1.GameCatalogItem) gamedomain.CatalogItem {
}
func normalizePlatform(item gamedomain.Platform) (gamedomain.Platform, error) {
// gRPC 边界只做通用字段清洗,厂商 JSON 的具体语义留给 adapter 解析。
item.PlatformCode = strings.TrimSpace(item.PlatformCode)
item.PlatformName = strings.TrimSpace(item.PlatformName)
item.Status = normalizeStatus(item.Status)
@ -387,6 +390,7 @@ func normalizeStatus(status string) string {
}
func normalizeAdapterType(adapterType string) string {
// 允许的 adapter 必须在这里登记,后台传错值时直接拒绝,避免落库后回调走错协议。
switch strings.ToLower(strings.TrimSpace(adapterType)) {
case "", gamedomain.AdapterDemo:
return gamedomain.AdapterDemo

View File

@ -54,6 +54,9 @@ grpc_client:
app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
leaderboard:
# 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。
wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true

View File

@ -51,6 +51,9 @@ grpc_client:
app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
leaderboard:
# 活动用户榜单只读钱包送礼事实;线上建议配置只读账号。
wallet_mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true

View File

@ -54,6 +54,9 @@ grpc_client:
app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
leaderboard:
# 活动用户榜单只读 wallet_transactions 的送礼事实;时间窗口统一按 UTC 计算。
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
auth_rate_limit:
# public auth 固定窗口频控Redis 不可用时 gateway 启动失败,避免失去入口保护。
enabled: true

View File

@ -2,6 +2,7 @@ package app
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
@ -11,6 +12,7 @@ import (
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
"google.golang.org/grpc"
"hyapp/pkg/grpcclient"
"hyapp/pkg/tencentcos"
@ -32,6 +34,7 @@ type App struct {
activityConn *grpc.ClientConn
gameConn *grpc.ClientConn
appConfig *appconfig.MySQLReader
leaderboard *sql.DB
redisClose func() error
health *healthcheck.State
closeOnce sync.Once
@ -106,6 +109,18 @@ func New(cfg config.Config) (*App, error) {
_ = gameConn.Close()
return nil, err
}
leaderboardWalletDB, err := openLeaderboardWalletDB(cfg.Leaderboard)
if err != nil {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
handler := httptransport.NewHandlerWithConfig(roomClient, roomGuardClient, userClient, userIdentityClient, httptransport.TencentIMConfig{
SDKAppID: cfg.TencentIM.SDKAppID,
SecretKey: cfg.TencentIM.SecretKey,
@ -128,6 +143,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
handler.SetLeaderboardWalletDB(leaderboardWalletDB)
if appConfigReader != nil {
handler.SetAppConfigReader(appConfigReader)
}
@ -152,6 +168,9 @@ func New(cfg config.Config) (*App, error) {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
if leaderboardWalletDB != nil {
_ = leaderboardWalletDB.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
@ -188,6 +207,9 @@ func New(cfg config.Config) (*App, error) {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
if leaderboardWalletDB != nil {
_ = leaderboardWalletDB.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
@ -203,6 +225,9 @@ func New(cfg config.Config) (*App, error) {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
if leaderboardWalletDB != nil {
_ = leaderboardWalletDB.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
@ -233,6 +258,9 @@ func New(cfg config.Config) (*App, error) {
if appConfigReader != nil {
_ = appConfigReader.Close()
}
if leaderboardWalletDB != nil {
_ = leaderboardWalletDB.Close()
}
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
@ -262,6 +290,7 @@ func New(cfg config.Config) (*App, error) {
activityConn: activityConn,
gameConn: gameConn,
appConfig: appConfigReader,
leaderboard: leaderboardWalletDB,
redisClose: redisClose,
health: healthState,
}, nil
@ -275,6 +304,14 @@ func openAppConfigReader(cfg config.AppConfigConfig) (*appconfig.MySQLReader, er
return appconfig.OpenMySQLReader(cfg.MySQLDSN)
}
func openLeaderboardWalletDB(cfg config.LeaderboardConfig) (*sql.DB, error) {
if strings.TrimSpace(cfg.WalletMySQLDSN) == "" {
return nil, nil
}
return sql.Open("mysql", strings.TrimSpace(cfg.WalletMySQLDSN))
}
func configureAuthRateLimit(handler *httptransport.Handler, cfg config.AuthRateLimitConfig, rateLimitConfig httptransport.AuthRateLimitConfig) (func() error, error) {
if !rateLimitConfig.Enabled {
// 显式关闭频控时仍记录配置,避免 handler 使用默认开启策略。
@ -376,6 +413,9 @@ func (a *App) Close() error {
if a.appConfig != nil {
err = errors.Join(err, a.appConfig.Close())
}
if a.leaderboard != nil {
err = errors.Join(err, a.leaderboard.Close())
}
if a.redisClose != nil {
err = errors.Join(err, a.redisClose())
}

View File

@ -28,6 +28,7 @@ type Config struct {
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
LoginRisk LoginRiskConfig `yaml:"login_risk"`
AppConfig AppConfigConfig `yaml:"app_config"`
Leaderboard LeaderboardConfig `yaml:"leaderboard"`
TencentIM TencentIMConfig `yaml:"tencent_im"`
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
@ -113,6 +114,12 @@ type AppConfigConfig struct {
MySQLDSN string `yaml:"mysql_dsn"`
}
// LeaderboardConfig 描述 App 活动榜单读取钱包送礼事实的只读数据源。
type LeaderboardConfig struct {
// WalletMySQLDSN 指向钱包库 hyapp_wallet只读取 wallet_transactions 的 gift_debit 事实。
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
}
// AuthRateLimitConfig 控制 gateway public auth 入口的 Redis 频控。
type AuthRateLimitConfig struct {
// Enabled 为 false 时关闭 gateway auth 入口频控。
@ -248,6 +255,9 @@ func Default() Config {
AppConfig: AppConfigConfig{
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
},
Leaderboard: LeaderboardConfig{
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
},
AuthRateLimit: AuthRateLimitConfig{
Enabled: true,
RedisAddr: "127.0.0.1:13379",
@ -361,6 +371,7 @@ func (cfg *Config) Normalize() error {
RetryableStatusCodes: runtimeGRPC.RetryableStatusCodes,
}
cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN)
cfg.Leaderboard.WalletMySQLDSN = strings.TrimSpace(cfg.Leaderboard.WalletMySQLDSN)
cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr)
cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix)
if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr == "" {

View File

@ -1,6 +1,8 @@
package activityapi
import (
"database/sql"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
@ -11,7 +13,9 @@ type Handler struct {
taskClient client.TaskClient
growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient
userProfileClient client.UserProfileClient
walletClient client.WalletClient
walletDB *sql.DB
registrationReward client.RegistrationRewardClient
sevenDayCheckIn client.SevenDayCheckInClient
}
@ -20,7 +24,9 @@ type Config struct {
TaskClient client.TaskClient
GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient
UserProfileClient client.UserProfileClient
WalletClient client.WalletClient
WalletDB *sql.DB
RegistrationReward client.RegistrationRewardClient
SevenDayCheckIn client.SevenDayCheckInClient
}
@ -30,7 +36,9 @@ func New(config Config) *Handler {
taskClient: config.TaskClient,
growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient,
userProfileClient: config.UserProfileClient,
walletClient: config.WalletClient,
walletDB: config.WalletDB,
registrationReward: config.RegistrationReward,
sevenDayCheckIn: config.SevenDayCheckIn,
}
@ -43,6 +51,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
SignSevenDayCheckIn: h.signSevenDayCheckIn,
ListUserLeaderboards: h.listUserLeaderboards,
}
}

View File

@ -0,0 +1,441 @@
package activityapi
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
userv1 "hyapp.local/api/proto/user/v1"
)
const (
leaderboardTypeSent = "sent"
leaderboardTypeReceived = "received"
leaderboardTypeRoom = "room"
leaderboardPeriodToday = "today"
leaderboardPeriodWeek = "week"
leaderboardPeriodMonth = "month"
userLeaderboardMaxPageSize = 100
)
type userLeaderboardResponse struct {
Items []userLeaderboardItem `json:"items"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `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 *userLeaderboardItem `json:"my_rank,omitempty"`
}
type userLeaderboardItem 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 *userLeaderboardUser `json:"user,omitempty"`
Room *userLeaderboardRoom `json:"room,omitempty"`
}
type userLeaderboardUser struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type userLeaderboardRoom struct {
RoomID string `json:"room_id"`
}
func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http.Request) {
if h.walletDB == nil {
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
return
}
boardType := normalizeUserLeaderboardType(request.URL.Query().Get("board_type"))
if boardType == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
period := normalizeUserLeaderboardPeriod(request.URL.Query().Get("period"))
if period == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if pageSize > userLeaderboardMaxPageSize {
pageSize = userLeaderboardMaxPageSize
}
now := time.Now().UTC()
start, end := userLeaderboardPeriodWindow(period, now)
startMS := start.UnixMilli()
endMS := end.UnixMilli()
app := appcode.FromContext(request.Context())
total, err := countUserLeaderboard(request.Context(), h.walletDB, app, boardType, startMS, endMS)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
items, err := listUserLeaderboardItems(request.Context(), h.walletDB, app, boardType, startMS, endMS, int(pageSize), int((page-1)*pageSize))
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var myRank *userLeaderboardItem
viewerUserID := auth.UserIDFromContext(request.Context())
if viewerUserID > 0 && boardType != leaderboardTypeRoom {
item, found, err := userLeaderboardRankForUser(request.Context(), h.walletDB, app, boardType, startMS, endMS, viewerUserID)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if found {
myRank = &item
}
}
if err := h.enrichUserLeaderboardItems(request, boardType, items, myRank); err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
httpkit.WriteOK(writer, request, userLeaderboardResponse{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
BoardType: boardType,
Period: period,
StartAtMS: startMS,
EndAtMS: endMS,
ServerTimeMS: now.UnixMilli(),
MyRank: myRank,
})
}
func normalizeUserLeaderboardType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "sent", "send", "sender", "gift_sent", "user_sent":
return leaderboardTypeSent
case "received", "receive", "receiver", "gift_received", "user_received":
return leaderboardTypeReceived
case "room", "rooms", "room_gift", "room_gifts":
return leaderboardTypeRoom
default:
return ""
}
}
func normalizeUserLeaderboardPeriod(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "today", "day", "daily":
return leaderboardPeriodToday
case "week", "weekly":
return leaderboardPeriodWeek
case "month", "monthly":
return leaderboardPeriodMonth
default:
return ""
}
}
func userLeaderboardPeriodWindow(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 leaderboardPeriodWeek:
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
return dayStart.AddDate(0, 0, 1-weekday), utcNow
case leaderboardPeriodMonth:
return time.Date(utcNow.Year(), utcNow.Month(), 1, 0, 0, 0, 0, time.UTC), utcNow
default:
return dayStart, utcNow
}
}
func countUserLeaderboard(ctx context.Context, db *sql.DB, appCode string, boardType string, startMS int64, endMS int64) (int64, error) {
subject, predicate := userLeaderboardDimension(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 := db.QueryRowContext(ctx, query, appCode, startMS, endMS).Scan(&total)
return total, err
}
func listUserLeaderboardItems(ctx context.Context, db *sql.DB, appCode string, boardType string, startMS int64, endMS int64, limit int, offset int) ([]userLeaderboardItem, error) {
subject, predicate := userLeaderboardDimension(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 := db.QueryContext(ctx, query, appCode, startMS, endMS, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]userLeaderboardItem, 0, limit)
for rows.Next() {
item, err := scanUserLeaderboardItem(rows, boardType)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func userLeaderboardRankForUser(ctx context.Context, db *sql.DB, appCode string, boardType string, startMS int64, endMS int64, userID int64) (userLeaderboardItem, bool, error) {
subject, predicate := userLeaderboardDimension(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 := db.QueryRowContext(ctx, query, appCode, startMS, endMS, userID)
item, err := scanUserLeaderboardItem(row, boardType)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return userLeaderboardItem{}, false, nil
}
return userLeaderboardItem{}, false, err
}
return item, true, nil
}
type userLeaderboardScanner interface {
Scan(dest ...any) error
}
func scanUserLeaderboardItem(scanner userLeaderboardScanner, boardType string) (userLeaderboardItem, error) {
var item userLeaderboardItem
var subject string
if err := scanner.Scan(&item.Rank, &subject, &item.GiftValue, &item.GiftCount, &item.TransactionCount, &item.LastGiftAtMS); err != nil {
return userLeaderboardItem{}, err
}
if boardType == leaderboardTypeRoom {
item.RoomID = subject
item.Room = &userLeaderboardRoom{RoomID: subject}
return item, nil
}
item.UserID = subject
item.User = &userLeaderboardUser{UserID: subject}
return item, nil
}
func userLeaderboardDimension(boardType string) (string, string) {
switch boardType {
case leaderboardTypeReceived:
return "target_user_id", "target_user_id > 0"
case leaderboardTypeRoom:
return "room_id", "room_id <> ''"
default:
return "sender_user_id", "sender_user_id > 0"
}
}
func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType string, items []userLeaderboardItem, myRank *userLeaderboardItem) error {
if boardType == leaderboardTypeRoom {
return nil
}
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 := h.userLeaderboardProfiles(request, userIDs)
if err != nil {
return err
}
for i := range items {
applyUserLeaderboardProfile(&items[i], profiles)
}
if myRank != nil {
applyUserLeaderboardProfile(myRank, profiles)
}
return nil
}
func (h *Handler) userLeaderboardProfiles(request *http.Request, userIDs []int64) (map[int64]*userv1.User, error) {
result := make(map[int64]*userv1.User)
if h.userProfileClient == nil || len(userIDs) == 0 {
return result, nil
}
userIDs = uniqueUserLeaderboardInt64s(userIDs)
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
return nil, err
}
return resp.GetUsers(), nil
}
func applyUserLeaderboardProfile(item *userLeaderboardItem, profiles map[int64]*userv1.User) {
if item == nil {
return
}
id, err := strconv.ParseInt(item.UserID, 10, 64)
if err != nil || id <= 0 {
return
}
profile := profiles[id]
if profile == nil {
return
}
item.User = &userLeaderboardUser{
UserID: strconv.FormatInt(profile.GetUserId(), 10),
DisplayUserID: profile.GetDisplayUserId(),
Username: profile.GetUsername(),
Avatar: profile.GetAvatar(),
}
}
func uniqueUserLeaderboardInt64s(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
}

View File

@ -0,0 +1,40 @@
package activityapi
import (
"testing"
"time"
)
func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) {
now := time.Date(2026, 5, 20, 15, 30, 0, 0, time.FixedZone("CST", 8*60*60))
todayStart, todayEnd := userLeaderboardPeriodWindow(leaderboardPeriodToday, now)
if todayStart != time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) {
t.Fatalf("today start = %s", todayStart)
}
if !todayEnd.Equal(now.UTC()) {
t.Fatalf("today end = %s", todayEnd)
}
weekStart, _ := userLeaderboardPeriodWindow(leaderboardPeriodWeek, now)
if weekStart != time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) {
t.Fatalf("week start = %s", weekStart)
}
monthStart, _ := userLeaderboardPeriodWindow(leaderboardPeriodMonth, now)
if monthStart != time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) {
t.Fatalf("month start = %s", monthStart)
}
}
func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) {
if normalizeUserLeaderboardType("receiver") != leaderboardTypeReceived {
t.Fatal("receiver should normalize to received")
}
if normalizeUserLeaderboardType("rooms") != leaderboardTypeRoom {
t.Fatal("rooms should normalize to room")
}
if normalizeUserLeaderboardPeriod("monthly") != leaderboardPeriodMonth {
t.Fatal("monthly should normalize to month")
}
}

View File

@ -2,6 +2,7 @@ package http
import (
"context"
"database/sql"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
"net/http"
@ -42,6 +43,7 @@ type Handler struct {
broadcastClient client.BroadcastClient
gameClient client.GameClient
appConfigReader appapi.ConfigReader
leaderboardWalletDB *sql.DB
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
objectUploader ObjectUploader
@ -206,6 +208,11 @@ func (h *Handler) SetGameClient(gameClient client.GameClient) {
h.gameClient = gameClient
}
// SetLeaderboardWalletDB 注入 App 榜单只读钱包库连接。
func (h *Handler) SetLeaderboardWalletDB(db *sql.DB) {
h.leaderboardWalletDB = db
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appapi.ConfigReader) {
h.appConfigReader = reader

View File

@ -131,6 +131,7 @@ type TaskHandlers struct {
GetRegistrationRewardEligibility http.HandlerFunc
GetSevenDayCheckInStatus http.HandlerFunc
SignSevenDayCheckIn http.HandlerFunc
ListUserLeaderboards http.HandlerFunc
}
type LevelHandlers struct {
@ -323,6 +324,7 @@ func (r routes) registerTaskRoutes() {
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility)
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)
r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn)
r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards)
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
r.profile("/tasks/claim", http.MethodPost, h.ClaimTaskReward)
}

View File

@ -60,7 +60,9 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
TaskClient: h.taskClient,
GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient,
UserProfileClient: h.userProfileClient,
WalletClient: h.walletClient,
WalletDB: h.leaderboardWalletDB,
RegistrationReward: h.registrationReward,
SevenDayCheckIn: h.sevenDayCheckIn,
})