灵仙和小游游戏
This commit is contained in:
parent
61f4e31b6a
commit
21dd835c75
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
87
server/admin/internal/modules/userleaderboard/handler.go
Normal file
87
server/admin/internal/modules/userleaderboard/handler.go
Normal 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 ""
|
||||
}
|
||||
15
server/admin/internal/modules/userleaderboard/routes.go
Normal file
15
server/admin/internal/modules/userleaderboard/routes.go
Normal 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)
|
||||
}
|
||||
578
server/admin/internal/modules/userleaderboard/service.go
Normal file
578
server/admin/internal/modules/userleaderboard/service.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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_id;uid 取值由 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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_id;display_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 的闭环校验。
|
||||
// 当前实现直接复用启动 token:Yomi 后续 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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -54,6 +54,9 @@ grpc_client:
|
||||
app_config:
|
||||
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 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
|
||||
|
||||
@ -51,6 +51,9 @@ grpc_client:
|
||||
app_config:
|
||||
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 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
|
||||
|
||||
@ -54,6 +54,9 @@ grpc_client:
|
||||
app_config:
|
||||
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user