送礼记录

This commit is contained in:
zhx 2026-07-14 20:23:12 +08:00
parent 449110aa95
commit d164dd5fd7
28 changed files with 1646 additions and 25 deletions

View File

@ -49,6 +49,7 @@ import (
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond"
giftrecordmodule "hyapp-admin-server/internal/modules/giftrecord"
healthmodule "hyapp-admin-server/internal/modules/health"
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
@ -383,6 +384,7 @@ func main() {
gamemanagementmodule.WithRobotAppearanceServices(walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn)),
),
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
GiftRecord: giftrecordmodule.New(userDB, walletDB),
Health: healthmodule.New(store, redisClient, jobStatus),
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),

View File

@ -114,12 +114,10 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
// login_audit 的复合索引把 login_type 放在 created_at_ms 之前;若把三种登录类型写成 IN
// MySQL 必须为每个用户扫描并排序全部候选,线上百万级审计表会超时。这里按类型拆成三个等值索引
// 范围,每个范围只取一条,再对最多三条候选按 (created_at_ms, id) 选最新记录,既保持原口径,
// 也避免窗口函数物化整张审计表。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
// 也避免窗口函数物化整张审计表。版本过滤必须放在 latest 选定之后:若先过滤审计行,最新登录
// 没带版本的用户会错误回退到旧版本。natural 是 MySQL 保留字,用户别名必须使用 natural_user。
rows, err := s.userDB.QueryContext(ctx, `
SELECT CASE
WHEN latest.app_version IS NULL OR TRIM(latest.app_version) = '' THEN 'unknown'
ELSE TRIM(latest.app_version)
END AS version_key,
SELECT TRIM(latest.app_version) AS version_key,
COUNT(*) AS user_count
FROM users AS natural_user
LEFT JOIN LATERAL (
@ -161,6 +159,9 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
WHERE natural_user.app_code = ?
AND natural_user.profile_completed = 1
AND LOWER(TRIM(COALESCE(natural_user.source, ''))) NOT IN ('game_robot', 'quick_account')
AND latest.app_version IS NOT NULL
AND TRIM(latest.app_version) <> ''
AND LOWER(TRIM(latest.app_version)) NOT IN ('unknown', '<nil>')
GROUP BY version_key
ORDER BY user_count DESC, version_key ASC`, appCode)
if err != nil {
@ -176,7 +177,11 @@ func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Co
return nil, err
}
key = normalizeDistributionKey(key)
items = append(items, UserProfileDistributionItem{Key: key, Label: appVersionDistributionLabel(key), Count: count})
// SQL 已排除所有无版本用户;这里仍守住响应边界,避免测试替身或历史脏值把“未知版本”带回前端。
if key == "unknown" {
continue
}
items = append(items, UserProfileDistributionItem{Key: key, Label: key, Count: count})
}
if err := rows.Err(); err != nil {
return nil, err
@ -206,10 +211,3 @@ func genderDistributionLabel(key string) string {
return key
}
}
func appVersionDistributionLabel(key string) string {
if key == "unknown" {
return "未知版本"
}
return key
}

View File

@ -50,6 +50,8 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
('lalu', 6, 'male', 1, 'game_robot'),
('lalu', 7, 'female', 0, 'third_party'),
('lalu', 9, 'female', 1, 'third_party'),
('lalu', 10, 'male', 1, 'password'),
('lalu', 11, 'female', 1, 'password'),
('huwaa', 1, 'female', 1, 'third_party')`,
`INSERT INTO login_audit (app_code, user_id, login_type, app_version, result, blocked, created_at_ms) VALUES
('lalu', 1, 'password', '1.0.0', 'success', 0, 100),
@ -65,6 +67,8 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
('lalu', 6, 'password', 'excluded', 'success', 0, 500),
('lalu', 7, 'password', 'excluded', 'success', 0, 500),
('lalu', 9, 'password', ' ', 'success', 0, 500),
('lalu', 10, 'password', 'unknown', 'success', 0, 500),
('lalu', 11, 'password', '<nil>', 'success', 0, 500),
('huwaa', 1, 'password', 'other-app', 'success', 0, 500)`,
)
@ -74,10 +78,9 @@ func TestUserProfileVersionDistributionRealMySQL(t *testing.T) {
if err != nil {
t.Fatalf("query real MySQL version distribution: %v", err)
}
if len(items) != 3 ||
items[0] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) ||
items[1] != (UserProfileDistributionItem{Key: "3.0.0", Label: "3.0.0", Count: 1}) ||
items[2] != (UserProfileDistributionItem{Key: "time-wins", Label: "time-wins", Count: 1}) {
if len(items) != 2 ||
items[0] != (UserProfileDistributionItem{Key: "3.0.0", Label: "3.0.0", Count: 1}) ||
items[1] != (UserProfileDistributionItem{Key: "time-wins", Label: "time-wins", Count: 1}) {
t.Fatalf("version distribution mismatch: %+v", items)
}
}

View File

@ -29,7 +29,7 @@ func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.
AddRow("male", int64(4)).
AddRow("female", int64(2)).
AddRow("unknown", int64(1)))
sqlMock.ExpectQuery(`(?s)FROM users AS natural_user.*LEFT JOIN LATERAL.*login_type = 'password'.*ORDER BY audit.created_at_ms DESC, audit.id DESC.*UNION ALL.*login_type = 'third_party'.*UNION ALL.*login_type = 'refresh'.*ORDER BY candidate.created_at_ms DESC, candidate.id DESC.*WHERE natural_user.app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*GROUP BY version_key`).
sqlMock.ExpectQuery(`(?s)FROM users AS natural_user.*LEFT JOIN LATERAL.*login_type = 'password'.*ORDER BY audit.created_at_ms DESC, audit.id DESC.*UNION ALL.*login_type = 'third_party'.*UNION ALL.*login_type = 'refresh'.*ORDER BY candidate.created_at_ms DESC, candidate.id DESC.*WHERE natural_user.app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*latest.app_version IS NOT NULL.*TRIM\(latest.app_version\) <> ''.*NOT IN \('unknown', '<nil>'\).*GROUP BY version_key`).
WithArgs("huwaa").
WillReturnRows(sqlmock.NewRows([]string{"version_key", "user_count"}).
AddRow("2.4.0", int64(4)).
@ -84,12 +84,11 @@ func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.
envelope.Data.GenderDistribution[2] != (UserProfileDistributionItem{Key: "unknown", Label: "未知", Count: 1}) {
t.Fatalf("gender distribution mismatch: %+v", envelope.Data.GenderDistribution)
}
if len(envelope.Data.AppVersionDistribution) != 2 ||
envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) ||
envelope.Data.AppVersionDistribution[1] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) {
if len(envelope.Data.AppVersionDistribution) != 1 ||
envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) {
t.Fatalf("version distribution mismatch: %+v", envelope.Data.AppVersionDistribution)
}
// 版本分布以全部自然用户为分母并直接查询 login_auditstatistics-service 只保留新增/DAU 口径。
// 版本分布直接查询 login_audit 且只返回有最近登录版本的自然用户statistics-service 只保留新增/DAU 口径。
if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] {
t.Fatalf("statistics calls mismatch: %+v", requested)
}

View File

@ -29,6 +29,7 @@ const (
adapterVivaGamesV1 = "vivagames_v1"
adapterReyouV1 = "reyou_v1"
adapterZGameV1 = "zgame_v1"
adapterAMGV1 = "amg_v1"
adapterYomiV4 = "yomi_v4"
defaultGameStatus = "disabled"
defaultGameCategory = "casino"
@ -141,6 +142,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
return fetchReyouGameSyncPlan(platform, req)
case adapterZGameV1:
return fetchZGameGameSyncPlan(platform, req)
case adapterAMGV1:
return fetchAMGGameSyncPlan(platform, req)
case adapterYomiV4:
return fetchYomiGameSyncPlan(platform, req)
default:
@ -213,6 +216,14 @@ type zgameSyncConfig struct {
GameCovers map[string]string `json:"game_covers"`
}
type amgSyncConfig struct {
// AMG 文档未提供游戏列表 APIgame_urls 的 key 必须使用 settlement 回调中的数值 gameId。
GameURLs map[string]string `json:"game_urls"`
GameNames map[string]string `json:"game_names"`
GameIcons map[string]string `json:"game_icons"`
GameCovers map[string]string `json:"game_covers"`
}
type yomiSyncConfig struct {
// Yomi V4 文档只提供启动鉴权地址,没有游戏列表 API后台通过 game_names 维护截图里的 gameUid 清单。
GameNames map[string]string `json:"game_names"`
@ -392,6 +403,18 @@ func fetchZGameGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest)
}, nil
}
func fetchAMGGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeAMGSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
return providerGameSyncPlan{}, err
}
games, gameURLs := amgCatalogItems(platform.GetPlatformCode(), config, req)
if len(games) == 0 {
return providerGameSyncPlan{}, fmt.Errorf("AMG 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射")
}
return providerGameSyncPlan{Games: games, GameURLs: gameURLs}, nil
}
func fetchYomiGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeYomiSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
@ -429,6 +452,17 @@ func decodeZGameSyncConfig(raw string) (zgameSyncConfig, error) {
return config, nil
}
func decodeAMGSyncConfig(raw string) (amgSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return amgSyncConfig{}, nil
}
var config amgSyncConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return amgSyncConfig{}, fmt.Errorf("AMG 适配器配置 JSON 不合法")
}
return config, nil
}
func decodeYomiSyncConfig(raw string) (yomiSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return yomiSyncConfig{}, nil
@ -511,6 +545,42 @@ func zgameCatalogItems(platformCode string, config zgameSyncConfig, req syncGame
return items, gameURLs
}
func amgCatalogItems(platformCode string, config amgSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameURLs))
for providerGameID, launchURL := range config.GameURLs {
// settlement.gameId 是数值;同步阶段就拒绝非数值 key避免启动成功后回调永远匹配不到 session。
if value, err := strconv.ParseInt(strings.TrimSpace(providerGameID), 10, 64); err == nil && value > 0 && strings.TrimSpace(launchURL) != "" {
keys = append(keys, strings.TrimSpace(providerGameID))
}
}
sort.Strings(keys)
items := make([]catalogRequest, 0, len(keys))
gameURLs := make(map[string]string, len(keys))
for index, providerGameID := range keys {
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
gameURLs[providerGameID] = launchURL
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
items = append(items, catalogRequest{
GameID: stableGameID(platformCode, providerGameID),
PlatformCode: strings.TrimSpace(platformCode),
ProviderGameID: providerGameID,
GameName: name,
Category: defaulted(req.Category, defaultGameCategory),
IconURL: iconURL,
CoverURL: coverURL,
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterAMGV1}, req.Tags...)),
})
}
return items, gameURLs
}
func yomiCatalogItems(platformCode string, config yomiSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
keys := make([]string, 0, len(config.GameNames)+len(config.GameURLs))
seen := make(map[string]struct{}, len(config.GameNames)+len(config.GameURLs))

View File

@ -201,6 +201,36 @@ func TestFetchZGameGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
}
}
func TestFetchAMGGameSyncPlanReadsConfiguredNumericGameURLs(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "amg",
AdapterType: adapterAMGV1,
AdapterConfigJson: `{
"app_key":"kzphamrv01",
"game_urls":{
"1002":"https://dev.playamg.com/egyptslot/index.html",
"1001":"https://dev.playamg.com/greedy/index.html",
"not-a-game-id":"https://dev.playamg.com/ignored/index.html"
},
"game_names":{"1001":"Greedy","1002":"Egypt Slot"}
}`,
}, syncGamesRequest{})
if err != nil {
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
}
if len(plan.Games) != 2 {
t.Fatalf("games len = %d, want 2: %+v", len(plan.Games), plan.Games)
}
first := plan.Games[0]
second := plan.Games[1]
if first.GameID != "amg_1001" || first.ProviderGameID != "1001" || first.GameName != "Greedy" || second.ProviderGameID != "1002" {
t.Fatalf("AMG catalog mismatch: first=%+v second=%+v", first, second)
}
if plan.GameURLs["1001"] != "https://dev.playamg.com/greedy/index.html" || len(first.Tags) < 2 || first.Tags[1] != adapterAMGV1 {
t.Fatalf("AMG URL/tags mismatch: plan=%+v game=%+v", plan.GameURLs, first)
}
}
func TestFetchYomiGameSyncPlanReadsConfiguredGameNames(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "yomi",

View File

@ -0,0 +1,30 @@
package giftrecord
type userDTO struct {
UserID string `json:"userId"`
DisplayUserID string `json:"displayUserId,omitempty"`
DefaultDisplayUserID string `json:"defaultDisplayUserId,omitempty"`
PrettyDisplayUserID string `json:"prettyDisplayUserId,omitempty"`
PrettyID string `json:"prettyId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type giftDTO struct {
GiftID string `json:"giftId"`
Name string `json:"name"`
CoverURL string `json:"coverUrl"`
}
type recordDTO struct {
TransactionID string `json:"transactionId"`
Scene string `json:"scene"`
RoomID string `json:"roomId,omitempty"`
Sender userDTO `json:"sender"`
Receiver userDTO `json:"receiver"`
Gift giftDTO `json:"gift"`
GiftCount int32 `json:"giftCount"`
UnitValue int64 `json:"unitValue"`
GiftValue int64 `json:"giftValue"`
CreatedAtMS int64 `json:"createdAtMs"`
}

View File

@ -0,0 +1,87 @@
package giftrecord
import (
"database/sql"
"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(userDB *sql.DB, walletDB *sql.DB) *Handler {
return &Handler{service: NewService(userDB, walletDB)}
}
func (h *Handler) List(c *gin.Context) {
query, ok := parseListQuery(c)
if !ok {
return
}
items, total, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
if err != nil {
response.ServerError(c, "获取送礼记录失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
scene := strings.ToLower(strings.TrimSpace(firstQuery(c, "scene")))
if scene == "all" {
scene = ""
}
if scene != "" && scene != sceneRoom && scene != sceneDirect {
response.BadRequest(c, "送礼场景不正确")
return listQuery{}, false
}
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return listQuery{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return listQuery{}, false
}
if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS {
response.BadRequest(c, "时间区间不正确")
return listQuery{}, false
}
// 时间筛选统一为 [start_at_ms, end_at_ms),与账务和统计边界保持一致,避免相邻区间重复展示同一笔送礼。
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
Scene: scene,
SenderFilter: shared.UserIdentityFilterFromQuery(c, "sender"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}), true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
return parsed, err == nil && parsed >= 0
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,41 @@
package giftrecord
import (
"strings"
"hyapp-admin-server/internal/modules/shared"
)
const (
sceneRoom = "room"
sceneDirect = "direct"
)
type listQuery struct {
Page int
PageSize int
Scene string
SenderFilter shared.UserIdentityFilter
StartAtMS int64
EndAtMS int64
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 30
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.SenderFilter.UserID = strings.TrimSpace(query.SenderFilter.UserID)
query.SenderFilter.DisplayUserID = strings.TrimSpace(query.SenderFilter.DisplayUserID)
query.SenderFilter.Username = strings.TrimSpace(query.SenderFilter.Username)
return query
}
func listOffset(query listQuery) int {
return (query.Page - 1) * query.PageSize
}

View File

@ -0,0 +1,14 @@
package giftrecord
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/operations/gift-records", middleware.RequirePermission("gift-record:view"), h.List)
}

View File

@ -0,0 +1,349 @@
package giftrecord
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/modules/shared"
)
const (
bizTypeGiftDebit = "gift_debit"
bizTypeDirectGiftDebit = "direct_gift_debit"
walletStatusSucceeded = "succeeded"
)
type Service struct {
userDB *sql.DB
walletDB *sql.DB
}
type giftMetadata struct {
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftIconURL string `json:"gift_icon_url"`
GiftCount int32 `json:"gift_count"`
CoinPrice int64 `json:"coin_price"`
ChargeAmount int64 `json:"charge_amount"`
CoinSpent int64 `json:"coin_spent"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
RoomID string `json:"room_id"`
DirectGift bool `json:"direct_gift"`
}
type userProfile struct {
UserID int64
DisplayUserID string
DefaultDisplayUserID string
PrettyDisplayUserID string
PrettyID string
Username string
Avatar string
}
func NewService(userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userDB: userDB, walletDB: walletDB}
}
// List 只读取 wallet-service 已成功落账的交易快照:名称、封面、数量和价格都来自送礼发生时的 metadata
// 不回查当前礼物配置覆盖历史事实,也不扫描 room_outbox 这类会按保留策略清理的投递表。
func (s *Service) List(ctx context.Context, appCode string, query listQuery) ([]recordDTO, int64, error) {
query = normalizeListQuery(query)
if s == nil || s.walletDB == nil {
return nil, 0, fmt.Errorf("wallet mysql is not configured")
}
senderIDs, senderFiltered, err := s.resolveSenderFilter(ctx, appCode, query.SenderFilter)
if err != nil {
return nil, 0, err
}
if senderFiltered && len(senderIDs) == 0 {
// 用户身份筛选没有命中时直接返回空页,避免在钱包大表上构造永远为假的扫描条件。
return []recordDTO{}, 0, nil
}
whereSQL, args := giftRecordWhere(appCode, query, senderIDs)
var total int64
if err := s.walletDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM wallet_transactions "+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
// created_at_ms + transaction_id 组成稳定倒序;对应 owner 库组合索引,避免同一毫秒多笔交易跨页重复或遗漏。
rows, err := s.walletDB.QueryContext(ctx, `
SELECT transaction_id, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}'), created_at_ms
FROM wallet_transactions
`+whereSQL+`
ORDER BY created_at_ms DESC, transaction_id DESC
LIMIT ? OFFSET ?`, append(args, query.PageSize, listOffset(query))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]recordDTO, 0, query.PageSize)
userIDs := make([]int64, 0, query.PageSize*2)
for rows.Next() {
var item recordDTO
var bizType string
var metadataJSON string
if err := rows.Scan(&item.TransactionID, &bizType, &metadataJSON, &item.CreatedAtMS); err != nil {
return nil, 0, err
}
var metadata giftMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return nil, 0, fmt.Errorf("decode gift transaction %s metadata: %w", item.TransactionID, err)
}
item = recordFromMetadata(item, bizType, metadata)
items = append(items, item)
userIDs = append(userIDs, metadata.SenderUserID, metadata.TargetUserID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
profiles, err := s.userProfiles(ctx, appCode, userIDs)
if err != nil {
return nil, 0, err
}
for index := range items {
senderID, _ := strconv.ParseInt(items[index].Sender.UserID, 10, 64)
receiverID, _ := strconv.ParseInt(items[index].Receiver.UserID, 10, 64)
// 用户资料只补充公共用户组件所需展示字段;即使用户资料缺失,也保留账务快照中的长 ID不吞掉送礼事实。
if profile, ok := profiles[senderID]; ok {
items[index].Sender = userDTOFromProfile(profile)
}
if profile, ok := profiles[receiverID]; ok {
items[index].Receiver = userDTOFromProfile(profile)
}
}
return items, total, nil
}
func giftRecordWhere(appCode string, query listQuery, senderIDs []int64) (string, []any) {
where := "WHERE app_code = ? AND status = ?"
args := []any{strings.TrimSpace(appCode), walletStatusSucceeded}
switch query.Scene {
case sceneRoom:
where += " AND biz_type = ?"
args = append(args, bizTypeGiftDebit)
case sceneDirect:
where += " AND biz_type = ?"
args = append(args, bizTypeDirectGiftDebit)
default:
where += " AND biz_type IN (?, ?)"
args = append(args, bizTypeGiftDebit, bizTypeDirectGiftDebit)
}
if query.StartAtMS > 0 {
where += " AND created_at_ms >= ?"
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += " AND created_at_ms < ?"
args = append(args, query.EndAtMS)
}
if len(senderIDs) > 0 {
// gift_sender_user_id 是 wallet owner 从不可变 metadata 快照生成的索引列;后台不能为筛选跨库 JOIN 用户表,
// 因此先在 user DB 解析公共身份字段,再用内部 user_id 命中钱包组合索引完成分页。
where += " AND gift_sender_user_id IN (" + placeholders(len(senderIDs)) + ")"
for _, senderID := range senderIDs {
args = append(args, senderID)
}
}
return where, args
}
func (s *Service) resolveSenderFilter(ctx context.Context, appCode string, filter shared.UserIdentityFilter) ([]int64, bool, error) {
if filter.IsEmpty() {
return nil, false, nil
}
directUserIDs := make([]int64, 0, 1)
if userID, err := strconv.ParseInt(strings.TrimSpace(filter.UserID), 10, 64); err == nil && userID > 0 {
// 钱包事实必须在用户资料已删除或未同步时仍可按内部长 ID 检索。
directUserIDs = append(directUserIDs, userID)
}
if s == nil || s.userDB == nil {
if len(directUserIDs) > 0 {
return directUserIDs, true, nil
}
return nil, true, fmt.Errorf("user mysql is not configured")
}
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", filter, time.Now().UTC().UnixMilli())
args := append([]any{strings.TrimSpace(appCode)}, matchArgs...)
// 公共用户筛选允许昵称模糊匹配;最多解析 1000 个内部 ID既覆盖正常运营检索又限制后续钱包 IN 条件规模。
rows, err := s.userDB.QueryContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
ORDER BY u.updated_at_ms DESC, u.user_id DESC
LIMIT 1000`, args...)
if err != nil {
return nil, true, err
}
defer rows.Close()
userIDs := append([]int64(nil), directUserIDs...)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, true, err
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
return nil, true, err
}
return uniquePositiveInt64s(userIDs), true, nil
}
func recordFromMetadata(item recordDTO, bizType string, metadata giftMetadata) recordDTO {
item.Scene = sceneRoom
if bizType == bizTypeDirectGiftDebit || metadata.DirectGift {
item.Scene = sceneDirect
}
item.RoomID = strings.TrimSpace(metadata.RoomID)
item.Sender = userDTO{UserID: formatUserID(metadata.SenderUserID)}
item.Receiver = userDTO{UserID: formatUserID(metadata.TargetUserID)}
item.Gift = giftDTO{
GiftID: strings.TrimSpace(metadata.GiftID),
Name: firstNonEmpty(metadata.GiftName, metadata.GiftID, "-"),
CoverURL: strings.TrimSpace(metadata.GiftIconURL),
}
item.GiftCount = metadata.GiftCount
item.UnitValue = metadata.CoinPrice
item.GiftValue = metadata.CoinSpent
if item.GiftValue <= 0 {
item.GiftValue = metadata.ChargeAmount
}
if item.UnitValue <= 0 && item.GiftCount > 0 {
item.UnitValue = item.GiftValue / int64(item.GiftCount)
}
return item
}
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
result := make(map[int64]userProfile)
if s == nil || s.userDB == nil {
return result, nil
}
userIDs = uniquePositiveInt64s(userIDs)
if len(userIDs) == 0 {
return result, nil
}
nowMS := time.Now().UTC().UnixMilli()
args := []any{nowMS, nowMS, strings.TrimSpace(appCode)}
for _, userID := range userIDs {
args = append(args, userID)
}
// 每页最多查询 2 * page_size 个用户;靓号子查询均命中 (app_code,user_id,status) 索引,
// 这样公共用户组件能展示短 ID/靓号,又不会把用户表 join 进大体量钱包分页查询。
rows, err := s.userDB.QueryContext(ctx, `
SELECT users.user_id,
users.current_display_user_id,
COALESCE(users.default_display_user_id, ''),
COALESCE((
SELECT lease.display_user_id
FROM pretty_display_user_id_leases lease
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE((
SELECT pretty.pretty_id
FROM pretty_display_user_id_leases lease
JOIN pretty_display_ids pretty
ON pretty.app_code = lease.app_code
AND pretty.assigned_lease_id = lease.lease_id
AND pretty.status = 'assigned'
WHERE lease.app_code = users.app_code
AND lease.user_id = users.user_id
AND lease.status = 'active'
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
LIMIT 1
), ''),
COALESCE(users.username, ''), COALESCE(users.avatar, '')
FROM users
WHERE users.app_code = ? AND users.user_id IN (`+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.DefaultDisplayUserID,
&profile.PrettyDisplayUserID,
&profile.PrettyID,
&profile.Username,
&profile.Avatar,
); err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
func userDTOFromProfile(profile userProfile) userDTO {
return userDTO{
UserID: formatUserID(profile.UserID),
DisplayUserID: profile.DisplayUserID,
DefaultDisplayUserID: profile.DefaultDisplayUserID,
PrettyDisplayUserID: profile.PrettyDisplayUserID,
PrettyID: profile.PrettyID,
Username: profile.Username,
Avatar: profile.Avatar,
}
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func formatUserID(userID int64) string {
if userID <= 0 {
return ""
}
return strconv.FormatInt(userID, 10)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}

View File

@ -0,0 +1,122 @@
package giftrecord
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"hyapp-admin-server/internal/modules/shared"
)
func TestListUsesWalletGiftSnapshotAndEnrichesBothUsers(t *testing.T) {
walletDB, walletMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new wallet sqlmock: %v", err)
}
defer walletDB.Close()
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new user sqlmock: %v", err)
}
defer userDB.Close()
walletMock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM wallet_transactions WHERE app_code = ? AND status = ? AND biz_type IN (?, ?)")).
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
walletMock.ExpectQuery("SELECT transaction_id, biz_type, COALESCE\\(CAST\\(metadata_json AS CHAR\\), '\\{\\}'\\), created_at_ms").
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit, 30, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "biz_type", "metadata_json", "created_at_ms"}).AddRow(
"wallet-gift-1",
bizTypeGiftDebit,
`{"gift_id":"rose","gift_name":"玫瑰","gift_icon_url":"https://cdn.example/rose.webp","gift_count":3,"coin_price":100,"coin_spent":300,"sender_user_id":1001,"target_user_id":2002,"room_id":"room-9"}`,
int64(1770000000000),
))
userMock.ExpectQuery("SELECT users.user_id,").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(1001), int64(2002)).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar"}).
AddRow(int64(1001), "101", "101", "888888", "pretty-1", "送礼人", "sender.webp").
AddRow(int64(2002), "202", "202", "", "", "收礼人", "receiver.webp"))
items, total, err := NewService(userDB, walletDB).List(context.Background(), "fami", listQuery{Page: 1, PageSize: 30})
if err != nil {
t.Fatalf("list gift records: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("unexpected page total=%d items=%d", total, len(items))
}
item := items[0]
if item.Scene != sceneRoom || item.RoomID != "room-9" {
t.Fatalf("scene snapshot mismatch: %+v", item)
}
if item.Sender.Username != "送礼人" || item.Sender.PrettyDisplayUserID != "888888" || item.Receiver.Username != "收礼人" {
t.Fatalf("user enrichment mismatch: %+v", item)
}
if item.Gift.Name != "玫瑰" || item.Gift.CoverURL == "" || item.GiftCount != 3 || item.UnitValue != 100 || item.GiftValue != 300 {
t.Fatalf("gift snapshot mismatch: %+v", item)
}
if err := walletMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRecordFromMetadataFallsBackToTotalValueForHistoricalUnitPrice(t *testing.T) {
item := recordFromMetadata(recordDTO{}, bizTypeDirectGiftDebit, giftMetadata{
GiftID: "legacy-gift",
GiftCount: 2,
ChargeAmount: 240,
SenderUserID: 1,
TargetUserID: 2,
DirectGift: true,
})
if item.Scene != sceneDirect || item.Gift.Name != "legacy-gift" || item.UnitValue != 120 || item.GiftValue != 240 {
t.Fatalf("historical fallback mismatch: %+v", item)
}
}
func TestListResolvesCommonSenderIdentityFilterBeforeIndexedWalletPage(t *testing.T) {
walletDB, walletMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new wallet sqlmock: %v", err)
}
defer walletDB.Close()
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new user sqlmock: %v", err)
}
defer userDB.Close()
// 短 ID 先在 user owner 库解析为内部 user_id钱包查询只使用生成列组合索引不在 metadata JSON 上做运行时扫描。
userMock.ExpectQuery("SELECT u.user_id").
WithArgs("fami", "888888", "888888", sqlmock.AnyArg(), "888888").
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(int64(1001)))
walletMock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM wallet_transactions WHERE app_code = ? AND status = ? AND biz_type IN (?, ?) AND gift_sender_user_id IN (?)")).
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit, int64(1001)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
walletMock.ExpectQuery("SELECT transaction_id, biz_type, COALESCE\\(CAST\\(metadata_json AS CHAR\\), '\\{\\}'\\), created_at_ms").
WithArgs("fami", walletStatusSucceeded, bizTypeGiftDebit, bizTypeDirectGiftDebit, int64(1001), 30, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "biz_type", "metadata_json", "created_at_ms"}))
items, total, err := NewService(userDB, walletDB).List(context.Background(), "fami", listQuery{
Page: 1,
PageSize: 30,
SenderFilter: shared.UserIdentityFilter{
DisplayUserID: "888888",
},
})
if err != nil {
t.Fatalf("list gift records by sender: %v", err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("unexpected filtered page total=%d items=%d", total, len(items))
}
if err := walletMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@ -101,7 +101,7 @@ var (
}
operationsRead = []string{
"coin-ledger:view", "coin-seller-ledger:view", "report:view",
"gift-record:view", "coin-ledger:view", "coin-seller-ledger:view", "report:view",
}
operationsProductManage = []string{
"gift-diamond:view", "gift-diamond:update",

View File

@ -110,6 +110,7 @@ var defaultPermissions = []model.Permission{
{Name: "子币商申请审核", Code: "coin-seller-sub-application:audit", Kind: "button"},
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
{Name: "送礼记录查看", Code: "gift-record:view", Kind: "menu"},
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
{Name: "币商提现白名单查看", Code: "point-withdrawal-config:view", Kind: "menu"},
{Name: "币商提现白名单编辑", Code: "point-withdrawal-config:update", Kind: "button"},
@ -347,6 +348,7 @@ func (s *Store) seedMenus() error {
{ParentID: &resourceID, Title: "礼物列表", Code: "gift-list", Path: "/gifts", Icon: "gift", PermissionCode: "gift:view", Sort: 70, Visible: true},
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 71, Visible: true},
{ParentID: &resourceID, Title: "表情包列表", Code: "emoji-pack-list", Path: "/emoji-packs", Icon: "image", PermissionCode: "emoji-pack:view", Sort: 72, Visible: true},
{ParentID: &operationsID, Title: "送礼记录", Code: "operation-gift-records", Path: "/operations/gift-records", Icon: "gift", PermissionCode: "gift-record:view", Sort: 67, Visible: true},
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
{ParentID: &operationsID, Title: "币商流水", Code: "operation-coin-seller-ledger", Path: "/operations/coin-seller-ledger", Icon: "receipt", PermissionCode: "coin-seller-ledger:view", Sort: 69, Visible: true},
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 70, Visible: true},

View File

@ -5,6 +5,7 @@ import (
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
@ -181,6 +182,25 @@ func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
assertMigrationPermissionCodes(t, roleCodeFinanceSpecialist, financeMatches[1])
}
func TestGiftRecordPermissionMigrationExtendsOperationsReadRoles(t *testing.T) {
content, err := os.ReadFile("../../migrations/095_gift_record_navigation.sql")
if err != nil {
t.Fatalf("read gift record permission migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"'gift-record:view'",
"'ops-admin'",
"'operations-specialist'",
"'product-lead'",
"'product-specialist'",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("gift record migration missing %s", token)
}
}
}
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
@ -190,6 +210,9 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin
actual = append(actual, match[1])
}
expected := append([]string(nil), defaultRolePermissionCodes(roleCode)...)
// 093 已在生产执行不能为了新增页面回写旧迁移095 以增量方式给同一批运营只读岗位补 gift-record:view。
// 这里仍校验 093 的原始精确矩阵,新权限由上面的 095 专项测试锁定。
expected = stringSetDifference(expected, []string{"gift-record:view"})
sort.Strings(actual)
sort.Strings(expected)
if !reflect.DeepEqual(actual, expected) {

View File

@ -25,6 +25,7 @@ import (
"hyapp-admin-server/internal/modules/fullservernotice"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
"hyapp-admin-server/internal/modules/giftdiamond"
"hyapp-admin-server/internal/modules/giftrecord"
"hyapp-admin-server/internal/modules/health"
"hyapp-admin-server/internal/modules/hostagencypolicy"
"hyapp-admin-server/internal/modules/hostorg"
@ -89,6 +90,7 @@ type Handlers struct {
FullServerNotice *fullservernotice.Handler
Game *gamemanagement.Handler
GiftDiamond *giftdiamond.Handler
GiftRecord *giftrecord.Handler
Health *health.Handler
HostAgencyPolicy *hostagencypolicy.Handler
HostOrg *hostorg.Handler
@ -165,6 +167,7 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store,
riskconfig.RegisterRoutes(appProtected, h.RiskConfig)
gamemanagement.RegisterRoutes(appProtected, h.Game)
giftdiamond.RegisterRoutes(appProtected, h.GiftDiamond)
giftrecord.RegisterRoutes(appProtected, h.GiftRecord)
roomadmin.RegisterRoutes(appProtected, h.RoomAdmin)
roomrocket.RegisterRoutes(appProtected, h.RoomRocket)
roomturnoverreward.RegisterRoutes(appProtected, h.RoomTurnoverReward)

View File

@ -0,0 +1,37 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 送礼记录是 wallet-service 已成功送礼交易的只读审计入口;本迁移只写后台权限、菜单和固定岗位授权。
-- 迁移查询都命中 admin_permissions/admin_menus/admin_roles 的唯一索引,不扫描钱包或用户业务表。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('送礼记录查看', 'gift-record:view', 'menu', '允许查看真实用户房间及私聊送礼记录', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '送礼记录', 'operation-gift-records', '/operations/gift-records', 'gift', 'gift-record:view', 67, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'operations'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
-- 固定岗位中原本拥有运营只读模块的角色同步获得送礼记录;平台管理员也显式补齐,兼容未执行 bootstrap 的存量库。
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM admin_roles role
JOIN admin_permissions permission ON permission.code = 'gift-record:view'
WHERE role.code IN (
'platform-admin', 'ops-admin', 'operations-specialist',
'product-lead', 'product-specialist', 'auditor', 'readonly'
);

View File

@ -8,6 +8,7 @@ const (
SceneVoiceRoom = "voice_room"
PlatformCodeZGame = "zgame"
PlatformCodeAMG = "amg"
LaunchModeFullScreen = "full_screen"
LaunchModeHalfScreen = "half_screen"
@ -23,6 +24,7 @@ const (
AdapterVivaGamesV1 = "vivagames_v1"
AdapterReyouV1 = "reyou_v1"
AdapterZGameV1 = "zgame_v1"
AdapterAMGV1 = "amg_v1"
OrderStatusWalletApplying = "wallet_applying"
OrderStatusSucceeded = "succeeded"

View File

@ -0,0 +1,412 @@
package game
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"strconv"
"strings"
"time"
"unicode"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const (
// AMG 文档只固定成功码 0失败码使用稳定的 HTTP 语义数字,厂商只需按 code != 0 判失败。
amgCodeOK = 0
amgCodeInvalidRequest = 400
amgCodeUnauthorized = 401
amgCodeInsufficientBalance = 402
amgCodeInternalError = 500
amgDefaultTokenTTL = 24 * time.Hour
amgMaxEncryptedDataBytes = 256 * 1024
)
type amgAdapterConfig struct {
// app_key 是 AMG 分配给渠道的公开标识RSA 私钥单独存 callbackSecret不能混入可复制的 adapter JSON。
AppKey string `json:"app_key"`
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
// coin_scale 表示一个钱包最小单位对应的 AMG 小数精度;当前金币为整数时必须配置 1。
CoinScale int64 `json:"coin_scale"`
// AMG 没有游戏列表接口,运营按厂商 gameId 维护每款 H5 地址,后台据此生成目录候选项。
GameURLs map[string]string `json:"game_urls"`
LaunchURLTemplate string `json:"launch_url_template"`
// 以下参数均来自 AMG URL 文档;零值表示不拼接,避免擅自覆盖厂商游戏自己的默认行为。
Ext string `json:"ext"`
Mini int `json:"mini"`
AppAudio int `json:"app_audio"`
Debug int `json:"debug"`
UseLang int `json:"use_lang"`
Loading string `json:"loading"`
}
func amgConfigFromPlatform(value any) amgAdapterConfig {
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
raw = typed.AdapterConfigJSON
case gamedomain.LaunchableGame:
raw = typed.AdapterConfigJSON
}
var config amgAdapterConfig
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.AppKey = strings.TrimSpace(config.AppKey)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
if config.UIDMode == "" {
config.UIDMode = "display_user_id"
}
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
config.Ext = strings.TrimSpace(config.Ext)
config.Loading = strings.TrimSpace(config.Loading)
config.GameURLs = normalizeStringMap(config.GameURLs)
return config
}
func (c amgAdapterConfig) TokenTTL() time.Duration {
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
return amgDefaultTokenTTL
}
func (c amgAdapterConfig) CoinScaleValue() int64 {
// 只接受十进制精度,防止 3、7 等比例在 JSON 数字中产生无限小数并造成钱包金额歧义。
scale := c.CoinScale
if scale <= 0 {
return 1
}
for value := scale; value > 1; value /= 10 {
if value%10 != 0 {
return 1
}
}
if scale > 1_000_000 {
return 1
}
return scale
}
func amgLaunchBaseURL(game gamedomain.LaunchableGame, config amgAdapterConfig) string {
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
return raw
}
}
if config.LaunchURLTemplate == "" {
return ""
}
replacer := strings.NewReplacer(
"{provider_game_id}", strings.TrimSpace(game.ProviderGameID),
"{providerGameId}", strings.TrimSpace(game.ProviderGameID),
"{game_id}", strings.TrimSpace(game.GameID),
"{gameId}", strings.TrimSpace(game.GameID),
)
return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate))
}
func (s *Service) handleAMGOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, _ string) ([]byte, string, string, bool, string, error) {
// RSA 私钥只能证明 settlement 密文来自持有公钥的一方;来源白名单配置后仍应先挡掉非 AMG 机房请求。
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
return amgAdapterError(amgCodeUnauthorized, "ip restricted", false, "")
}
switch strings.ToLower(strings.Trim(strings.TrimSpace(operation), "/")) {
case "user", "amg/user":
return s.handleAMGUser(ctx, app, platform, req)
case "settlement", "amg/settlement":
return s.handleAMGSettlement(ctx, app, platform, req)
default:
return amgAdapterError(amgCodeInvalidRequest, "unsupported operation", false, "")
}
}
func (s *Service) handleAMGUser(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
token := amgAuthorizationToken(req)
session, err := s.validAMGSession(ctx, app, token, "")
if err != nil {
return amgAdapterError(amgCodeUnauthorized, "token invalid", false, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return amgAdapterError(amgCodeFromError(err), amgMessageFromError(err), true, "")
}
config := amgConfigFromPlatform(platform)
raw, contentType := amgJSON(amgCodeOK, "success", map[string]any{
"userId": externalUserID(session, config.UIDMode),
"nickname": snapshot.Nickname,
"avatarUrl": snapshot.Avatar,
"availableCoins": amgWalletCoinValue(snapshot.Balance, config.CoinScaleValue()),
"roomId": session.RoomID,
})
return raw, contentType, strconv.Itoa(amgCodeOK), true, "", nil
}
type amgSettlementEnvelope struct {
Data string `json:"data"`
}
type amgSettlementData struct {
TransactionID string `json:"transactionId"`
Coins json.Number `json:"coins"`
Type int32 `json:"type"`
Timestamp int64 `json:"timestamp"`
Ext string `json:"ext"`
GameID json.RawMessage `json:"gameId"`
}
func (s *Service) handleAMGSettlement(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
var envelope amgSettlementEnvelope
if err := decodeYomiJSON(req.GetRawBody(), &envelope); err != nil || strings.TrimSpace(envelope.Data) == "" || len(envelope.Data) > amgMaxEncryptedDataBytes*2 {
return amgAdapterError(amgCodeInvalidRequest, "invalid data", false, "")
}
// AMG 的 data 是 RSA PKCS#1 v1.5 分块密文再 hex 编码callbackSecret 保存不带 PEM 头的 PKCS#8 私钥原文。
plain, err := decryptAMGData(envelope.Data, platform.CallbackSecretCiphertext)
if err != nil {
return amgAdapterError(amgCodeUnauthorized, "data decrypt failed", false, "")
}
var body amgSettlementData
if err := decodeYomiJSON(plain, &body); err != nil {
return amgAdapterError(amgCodeInvalidRequest, "invalid settlement data", true, "")
}
body.TransactionID = strings.TrimSpace(body.TransactionID)
body.Ext = strings.TrimSpace(body.Ext)
gameID := reyouJSONText(body.GameID)
gameIDValue, gameIDOK := amgPositiveInt64(gameID)
config := amgConfigFromPlatform(platform)
amount, amountOK := amgWalletCoinAmount(body.Coins, config.CoinScaleValue())
opType := yomiOpType(body.Type)
if body.TransactionID == "" || len(body.TransactionID) > 64 || !gameIDOK || gameIDValue <= 0 || !amountOK || amount < 0 || opType == "" || body.Timestamp <= 0 {
return amgAdapterError(amgCodeInvalidRequest, "invalid settlement data", true, body.TransactionID)
}
token := amgAuthorizationToken(req)
session, err := s.validAMGSession(ctx, app, token, gameID)
if err != nil {
return amgAdapterError(amgCodeUnauthorized, "token invalid", true, body.TransactionID)
}
// RSA PKCS#1 v1.5 每次加密都会产生不同密文;幂等摘要必须基于解密后的业务字段,不能使用原始 callback body。
requestHash := stableHash(strings.Join([]string{
body.TransactionID,
strconv.FormatInt(amount, 10),
strconv.FormatInt(int64(body.Type), 10),
strconv.FormatInt(body.Timestamp, 10),
body.Ext,
gameID,
}, "|"))
result, err := s.applyYomiCoinChange(ctx, app, req, session, body.TransactionID, "", opType, amount, "", requestHash)
if err != nil {
code := amgCodeFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
raw, contentType := amgJSON(code, amgMessageFromError(err), map[string]any{
"availableCoins": amgWalletCoinValue(balance, config.CoinScaleValue()),
})
return raw, contentType, strconv.Itoa(code), true, body.TransactionID, nil
}
// 重复 transactionId 直接复用历史余额并返回成功applyYomiCoinChange 已保证不会再次调用钱包。
raw, contentType := amgJSON(amgCodeOK, "success", map[string]any{
"availableCoins": amgWalletCoinValue(result.BalanceAfter, config.CoinScaleValue()),
})
return raw, contentType, strconv.Itoa(amgCodeOK), true, body.TransactionID, nil
}
func (s *Service) validAMGSession(ctx context.Context, app string, token string, providerGameID string) (gamedomain.LaunchSession, error) {
token = strings.TrimSpace(token)
if token == "" {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
// 同一 App access token 可连续打开多个游戏;平台和可选 gameId 一起查询最新 session避免串到其它厂商或旧房间。
session, err := s.repository.GetLaunchSessionByTokenScope(ctx, app, token, gamedomain.PlatformCodeAMG, strings.TrimSpace(providerGameID))
if err != nil {
return gamedomain.LaunchSession{}, err
}
if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() {
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
}
return session, nil
}
func amgAuthorizationToken(req *gamev1.CallbackRequest) string {
if req == nil {
return ""
}
for _, key := range []string{"Authorization", "authorization"} {
value := strings.TrimSpace(req.GetHeaders()[key])
if len(value) >= len("Bearer ") && strings.EqualFold(value[:len("Bearer ")], "Bearer ") {
value = strings.TrimSpace(value[len("Bearer "):])
}
if value != "" {
return value
}
}
return ""
}
func decryptAMGData(data string, privateKeyValue string) ([]byte, error) {
data = strings.TrimSpace(data)
if data == "" || len(data) > amgMaxEncryptedDataBytes*2 {
return nil, fmt.Errorf("AMG ciphertext is empty or too large")
}
ciphertext, err := hex.DecodeString(data)
if err != nil || len(ciphertext) == 0 {
return nil, fmt.Errorf("decode AMG ciphertext: %w", err)
}
privateKey, err := parseAMGPrivateKey(privateKeyValue)
if err != nil {
return nil, err
}
chunkSize := privateKey.PublicKey.Size()
if len(ciphertext)%chunkSize != 0 {
return nil, fmt.Errorf("AMG ciphertext chunk size is invalid")
}
plain := make([]byte, 0, len(ciphertext))
for start := 0; start < len(ciphertext); start += chunkSize {
chunk, decryptErr := rsa.DecryptPKCS1v15(rand.Reader, privateKey, ciphertext[start:start+chunkSize])
if decryptErr != nil {
return nil, fmt.Errorf("decrypt AMG ciphertext: %w", decryptErr)
}
plain = append(plain, chunk...)
if len(plain) > amgMaxEncryptedDataBytes {
return nil, fmt.Errorf("AMG plaintext is too large")
}
}
return plain, nil
}
func parseAMGPrivateKey(value string) (*rsa.PrivateKey, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("AMG private key is empty")
}
var der []byte
if block, _ := pem.Decode([]byte(value)); block != nil {
der = block.Bytes
} else {
// 厂商交付文件是单行 base64 PKCS#8容忍复制时混入的换行但不修改实际密钥字节。
compact := strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, value)
decoded, err := base64.StdEncoding.DecodeString(compact)
if err != nil {
return nil, fmt.Errorf("decode AMG private key: %w", err)
}
der = decoded
}
if parsed, err := x509.ParsePKCS8PrivateKey(der); err == nil {
privateKey, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("AMG private key is not RSA")
}
if err := privateKey.Validate(); err != nil {
return nil, fmt.Errorf("validate AMG private key: %w", err)
}
return privateKey, nil
}
// 兼容厂商后续交付传统 PKCS#1 文件;当前 private(1).txt 已验证为 PKCS#8。
privateKey, err := x509.ParsePKCS1PrivateKey(der)
if err != nil {
return nil, fmt.Errorf("parse AMG private key: %w", err)
}
if err := privateKey.Validate(); err != nil {
return nil, fmt.Errorf("validate AMG private key: %w", err)
}
return privateKey, nil
}
func amgWalletCoinAmount(value json.Number, scale int64) (int64, bool) {
text := strings.TrimSpace(value.String())
if text == "" {
return 0, false
}
rat, ok := new(big.Rat).SetString(text)
if !ok {
return 0, false
}
rat.Mul(rat, new(big.Rat).SetInt64(scale))
if !rat.IsInt() || !rat.Num().IsInt64() {
return 0, false
}
return rat.Num().Int64(), true
}
func amgWalletCoinValue(value int64, scale int64) json.Number {
if scale <= 1 {
return json.Number(strconv.FormatInt(value, 10))
}
digits := len(strconv.FormatInt(scale, 10)) - 1
negative := value < 0
abs := value
if abs < 0 {
abs = -abs
}
text := fmt.Sprintf("%d.%0*d", abs/scale, digits, abs%scale)
text = strings.TrimRight(strings.TrimRight(text, "0"), ".")
if negative {
text = "-" + text
}
return json.Number(text)
}
func amgPositiveInt64(value string) (int64, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil && parsed > 0
}
func amgCodeFromError(err error) int {
switch {
case err == nil:
return amgCodeOK
case xerr.IsCode(err, xerr.InsufficientBalance):
return amgCodeInsufficientBalance
case xerr.IsCode(err, xerr.Unauthorized), xerr.IsCode(err, xerr.SessionExpired):
return amgCodeUnauthorized
case xerr.IsCode(err, xerr.InvalidArgument), xerr.IsCode(err, xerr.IdempotencyConflict):
return amgCodeInvalidRequest
default:
return amgCodeInternalError
}
}
func amgMessageFromError(err error) string {
switch amgCodeFromError(err) {
case amgCodeInsufficientBalance:
return "insufficient balance"
case amgCodeUnauthorized:
return "token invalid"
case amgCodeInvalidRequest:
return "invalid request"
default:
return "system error"
}
}
func amgAdapterError(code int, message string, authenticated bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
raw, contentType := amgJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), authenticated, providerRequestID, nil
}
func amgJSON(code int, message string, data any) ([]byte, string) {
if strings.TrimSpace(message) == "" {
message = "success"
}
if data == nil {
data = map[string]any{}
}
return jsonResponse(map[string]any{"code": code, "data": data, "msg": message})
}

View File

@ -0,0 +1,232 @@
package game
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/url"
"strconv"
"testing"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
func TestLaunchGameBuildsAMGURL(t *testing.T) {
repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{
CatalogItem: gamedomain.CatalogItem{
AppCode: "lalu",
GameID: "amg_1001",
PlatformCode: gamedomain.PlatformCodeAMG,
ProviderGameID: "1001",
GameName: "AMG Test",
Status: gamedomain.StatusActive,
Orientation: "portrait",
},
PlatformStatus: gamedomain.StatusActive,
AdapterType: gamedomain.AdapterAMGV1,
AdapterConfigJSON: `{
"app_key":"kzphamrv01",
"default_lang":"en",
"token_ttl_seconds":86400,
"mini":1,
"app_audio":1,
"use_lang":1,
"game_urls":{"1001":"https://dev.playamg.com/greedy/index.html"}
}`,
},
}
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
AppCode: "lalu",
UserID: 42,
DisplayUserID: "420001",
GameID: "amg_1001",
RoomID: "room_1",
AccessToken: "app_access_token_amg",
})
if err != nil {
t.Fatalf("LaunchGame failed: %v", err)
}
if result.ExpiresAtMS != 1700086400000 {
t.Fatalf("AMG launch must use adapter token ttl, got %+v", result)
}
parsed, err := url.Parse(result.LaunchURL)
if err != nil {
t.Fatalf("parse AMG launch URL: %v", err)
}
if got := parsed.Scheme + "://" + parsed.Host + parsed.Path; got != "https://dev.playamg.com/greedy/index.html" {
t.Fatalf("AMG launch base mismatch: %s", result.LaunchURL)
}
want := map[string]string{
"user_token": "app_access_token_amg",
"app_key": "kzphamrv01",
"room_id": "room_1",
"mini": "1",
"lang": "en",
"app_audio": "1",
"use_lang": "1",
}
for key, value := range want {
if got := parsed.Query().Get(key); got != value {
t.Fatalf("AMG query %s = %q, want %q URL=%s", key, got, value, result.LaunchURL)
}
}
}
func TestHandleAMGUserReturnsProfileAndWallet(t *testing.T) {
token := "amg_user_token"
repo := &fakeRepository{
platform: gamedomain.Platform{
PlatformCode: gamedomain.PlatformCodeAMG,
AdapterType: gamedomain.AdapterAMGV1,
AdapterConfigJSON: `{"uid_mode":"display_user_id","coin_scale":1}`,
},
session: gamedomain.LaunchSession{
AppCode: "lalu",
UserID: 42,
DisplayUserID: "420001",
RoomID: "room_1",
PlatformCode: gamedomain.PlatformCodeAMG,
GameID: "amg_1001",
ProviderGameID: "1001",
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: 1700086400000,
},
}
wallet := &fakeWallet{balanceAfter: 1200}
svc := New(Config{}, repo, wallet, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-amg-user"},
PlatformCode: gamedomain.PlatformCodeAMG,
Operation: "user",
Headers: map[string]string{"Authorization": token},
})
if err != nil {
t.Fatalf("HandleCallback failed: %v", err)
}
var response struct {
Code int `json:"code"`
Data struct {
UserID string `json:"userId"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatarUrl"`
AvailableCoins json.Number `json:"availableCoins"`
RoomID string `json:"roomId"`
} `json:"data"`
Message string `json:"msg"`
}
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatalf("decode AMG user response: %v body=%s", err, raw)
}
if response.Code != 0 || response.Message != "success" || response.Data.UserID != "420001" || response.Data.Nickname != "tester" || response.Data.AvatarURL == "" || response.Data.AvailableCoins.String() != "1200" || response.Data.RoomID != "room_1" {
t.Fatalf("AMG user response mismatch: %+v body=%s", response, raw)
}
}
func TestHandleAMGSettlementDecryptsAndIsIdempotentAcrossRandomCiphertexts(t *testing.T) {
privateKey, secret := amgTestPrivateKey(t)
token := "amg_settlement_token"
repo := &fakeRepository{
platform: gamedomain.Platform{
PlatformCode: gamedomain.PlatformCodeAMG,
AdapterType: gamedomain.AdapterAMGV1,
CallbackSecretCiphertext: secret,
AdapterConfigJSON: `{"coin_scale":1}`,
},
session: gamedomain.LaunchSession{
AppCode: "lalu",
UserID: 42,
DisplayUserID: "420001",
RoomID: "room_1",
RegionID: 100,
CountryID: 971,
PlatformCode: gamedomain.PlatformCodeAMG,
GameID: "amg_1001",
ProviderGameID: "1001",
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: 1700086400000,
},
}
wallet := &fakeWallet{balanceAfter: 900}
svc := New(Config{}, repo, wallet, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
plain := []byte(`{"transactionId":"amg-order-1","coins":100,"type":1,"timestamp":1700000000000,"ext":"room_game","gameId":1001}`)
for attempt := 0; attempt < 2; attempt++ {
encrypted := amgTestEncrypt(t, &privateKey.PublicKey, plain)
envelope, err := json.Marshal(map[string]string{"data": encrypted})
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-amg-settle-" + strconv.Itoa(attempt)},
PlatformCode: gamedomain.PlatformCodeAMG,
Operation: "settlement",
Headers: map[string]string{"Authorization": token},
RawBody: envelope,
})
if err != nil {
t.Fatalf("HandleCallback attempt %d failed: %v", attempt, err)
}
var response struct {
Code int `json:"code"`
Data struct {
AvailableCoins json.Number `json:"availableCoins"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &response); err != nil || response.Code != 0 || response.Data.AvailableCoins.String() != "900" {
t.Fatalf("AMG settlement response mismatch: err=%v response=%+v body=%s", err, response, raw)
}
}
if wallet.applyCount != 1 || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 100 || wallet.lastApply.GetProviderOrderId() != "amg-order-1" {
t.Fatalf("AMG wallet change mismatch: count=%d request=%+v", wallet.applyCount, wallet.lastApply)
}
wantHash := stableHash("amg-order-1|100|1|1700000000000|room_game|1001")
if repo.order.RequestHash != wantHash || repo.order.Status != gamedomain.OrderStatusSucceeded {
t.Fatalf("AMG order idempotency mismatch: %+v want_hash=%s", repo.order, wantHash)
}
}
func amgTestPrivateKey(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("generate RSA key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatalf("marshal PKCS#8 key: %v", err)
}
return privateKey, base64.StdEncoding.EncodeToString(der)
}
func amgTestEncrypt(t *testing.T, publicKey *rsa.PublicKey, plain []byte) string {
t.Helper()
chunkSize := publicKey.Size() - 11
ciphertext := make([]byte, 0, ((len(plain)+chunkSize-1)/chunkSize)*publicKey.Size())
for start := 0; start < len(plain); start += chunkSize {
end := start + chunkSize
if end > len(plain) {
end = len(plain)
}
chunk, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, plain[start:end])
if err != nil {
t.Fatalf("encrypt AMG test chunk: %v", err)
}
ciphertext = append(ciphertext, chunk...)
}
return hex.EncodeToString(ciphertext)
}

View File

@ -309,7 +309,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := strings.TrimSpace(command.AccessToken)
if token == "" {
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
}
token = "gt_" + randomHex(24)
@ -318,6 +318,10 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
// ZGame 文档要求 js_code 是客户服务器派发的短期凭证;不能把 App access token 泄漏到三方 H5 URL。
token = "gt_" + randomHex(24)
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) && len(token) > 512 {
// AMG 明确限制 user_token 最长 512 字节;启动前拒绝,避免 H5 打开后才在 /amg/user 出现不可诊断的鉴权失败。
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "amg user_token exceeds 512 bytes")
}
sessionTTL := s.config.LaunchSessionTTL
// 三方游戏会在 H5 运行期间持续回调 token厂商适配器可以把默认 15 分钟启动 TTL 拉长。
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
@ -334,6 +338,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionTTL = maxDuration(sessionTTL, reyouConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
sessionTTL = maxDuration(sessionTTL, zgameConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
sessionTTL = maxDuration(sessionTTL, amgConfigFromPlatform(game).TokenTTL())
}
expiresAt := now.Add(sessionTTL).UnixMilli()
session := gamedomain.LaunchSession{
@ -411,6 +417,8 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleReyouOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterZGameV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZGameOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterAMGV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleAMGOperation(ctx, app, platform, req, operation, requestHash)
default:
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
}
@ -853,6 +861,12 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
base = configuredBase
}
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
// AMG 按 gameId 分配逐游戏 H5 地址;后台 game_urls 优先于平台级兜底地址。
if configuredBase := amgLaunchBaseURL(game, amgConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
}
if base == "" {
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
@ -872,6 +886,9 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
return "", xerr.New(xerr.InvalidArgument, "zgame launch url is required")
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
return "", xerr.New(xerr.InvalidArgument, "amg launch url is required")
}
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
base = "https://game.example.local/h5"
}
@ -1041,6 +1058,42 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
config := amgConfigFromPlatform(game)
if config.AppKey == "" {
return "", xerr.New(xerr.InvalidArgument, "amg app_key is required")
}
// AMG 文档固定使用 user_token/app_key可选项只在后台明确配置后才拼接避免改变厂商默认 UI。
query.Set("user_token", token)
query.Set("app_key", config.AppKey)
if config.Ext != "" {
query.Set("ext", config.Ext)
}
if session.RoomID != "" {
query.Set("room_id", session.RoomID)
}
if config.Mini > 0 {
query.Set("mini", strconv.Itoa(config.Mini))
}
if config.DefaultLang != "" {
query.Set("lang", config.DefaultLang)
}
if config.AppAudio > 0 {
query.Set("app_audio", strconv.Itoa(config.AppAudio))
}
if config.Debug > 0 {
query.Set("debug", strconv.Itoa(config.Debug))
}
if config.UseLang > 0 {
query.Set("use_lang", strconv.Itoa(config.UseLang))
}
if config.Loading != "" {
query.Set("loading", config.Loading)
}
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
query.Set("session_id", session.SessionID)
query.Set("session_token", token)

View File

@ -1245,6 +1245,8 @@ func normalizeAdapterType(adapterType string) string {
return gamedomain.AdapterReyouV1
case gamedomain.AdapterZGameV1:
return gamedomain.AdapterZGameV1
case gamedomain.AdapterAMGV1:
return gamedomain.AdapterAMGV1
default:
return ""
}

View File

@ -26,7 +26,7 @@ func TestPlatformToProtoExposesCallbackSecretForAdmin(t *testing.T) {
func TestNormalizePlatformAcceptsProviderAdapters(t *testing.T) {
// 平台创建入口会先规范化 adapter_type新增厂商必须在这里放行否则后台配置页会被 gRPC 直接拒绝。
for _, adapterType := range []string{gamedomain.AdapterVivaGamesV1, gamedomain.AdapterReyouV1, gamedomain.AdapterZGameV1} {
for _, adapterType := range []string{gamedomain.AdapterVivaGamesV1, gamedomain.AdapterReyouV1, gamedomain.AdapterZGameV1, gamedomain.AdapterAMGV1} {
got, err := normalizePlatform(gamedomain.Platform{
PlatformCode: strings.TrimSuffix(adapterType, "_v1"),
PlatformName: adapterType,

View File

@ -573,6 +573,47 @@ func TestZGameFixedCallbackRouteIsRawPublicResponse(t *testing.T) {
}
}
func TestAMGFixedCallbackRoutesUseAuthorizationTokenAppAndRawResponse(t *testing.T) {
token := signGatewayTokenWithAppCode(t, "secret", 42, "fami")
tests := []struct {
name string
method string
path string
body string
operation string
}{
{name: "user", method: http.MethodGet, path: "/api/v1/amg/user?room=13", operation: "user"},
{name: "settlement", method: http.MethodPost, path: "/api/v1/amg/settlement", body: `{"data":"aabb"}`, operation: "settlement"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0,"data":{},"msg":"success"}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(tt.method, tt.path, bytes.NewReader([]byte(tt.body)))
// AMG 文档发送 token 原文而不是 Bearergateway 仍需验签后从 claims 解析真实 App。
request.Header.Set("Authorization", token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"code":0,"data":{},"msg":"success"}` {
t.Fatalf("AMG response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCallback == nil || gameClient.lastCallback.GetPlatformCode() != "amg" || gameClient.lastCallback.GetOperation() != tt.operation || gameClient.lastCallback.GetMeta().GetAppCode() != "fami" || gameClient.lastCallback.GetMeta().GetActorUserId() != 42 {
t.Fatalf("AMG callback mismatch: %+v", gameClient.lastCallback)
}
if tt.operation == "user" && (len(gameClient.lastCallback.GetRawBody()) != 0 || gameClient.lastCallback.GetQuery()["room"] != "13") {
t.Fatalf("AMG user callback payload mismatch: %+v", gameClient.lastCallback)
}
if tt.operation == "settlement" && string(gameClient.lastCallback.GetRawBody()) != tt.body {
t.Fatalf("AMG settlement body mismatch: %s", gameClient.lastCallback.GetRawBody())
}
})
}
}
func TestGameCallbackUsesVerifiedTokenAppCodeFromBodyQueryAndHeader(t *testing.T) {
token := signGatewayTokenWithAppCode(t, "secret", 42, "yumi")
tests := []struct {

View File

@ -780,6 +780,21 @@ func (h *Handler) handleZGameCallback(writer http.ResponseWriter, request *http.
h.handleGameCallbackRaw(writer, request, raw, zgameCallbackPlatformCode, operation)
}
func (h *Handler) handleAMGUser(writer http.ResponseWriter, request *http.Request) {
// AMG 获取玩家信息是 GET 且没有 bodyAuthorization 中的游戏 token 仍由统一回调入口验签并用于 App 归因。
h.handleGameCallbackRaw(writer, request, nil, "amg", "user")
}
func (h *Handler) handleAMGSettlement(writer http.ResponseWriter, request *http.Request) {
raw, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, "invalid request body", http.StatusBadRequest)
return
}
// settlement 的 RSA 解密、订单幂等和钱包改账都归 game-servicegateway 只保留厂商原始 JSON。
h.handleGameCallbackRaw(writer, request, raw, "amg", "settlement")
}
func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *http.Request, raw []byte, platformCode string, operation string) {
if h.gameClient == nil {
http.Error(writer, "upstream service error", http.StatusBadGateway)

View File

@ -71,6 +71,8 @@ func (h *Handler) Handlers() httproutes.GameHandlers {
GetRoomRPSChallenge: h.getRoomRPSChallenge,
HandleCallback: h.handleGameCallback,
HandleZGameCallback: h.handleZGameCallback,
HandleAMGUser: h.handleAMGUser,
HandleAMGSettlement: h.handleAMGSettlement,
HandleHotgameGetUserInfo: h.handleHotgameGetUserInfo,
HandleHotgameUpdateBalance: h.handleHotgameUpdateBalance,
}
@ -176,6 +178,14 @@ func (h *Handler) HandleCallback(writer http.ResponseWriter, request *http.Reque
h.handleGameCallback(writer, request)
}
func (h *Handler) HandleAMGUser(writer http.ResponseWriter, request *http.Request) {
h.handleAMGUser(writer, request)
}
func (h *Handler) HandleAMGSettlement(writer http.ResponseWriter, request *http.Request) {
h.handleAMGSettlement(writer, request)
}
func (h *Handler) HandleHotgameGetUserInfo(writer http.ResponseWriter, request *http.Request) {
h.handleHotgameGetUserInfo(writer, request)
}

View File

@ -347,6 +347,8 @@ type GameHandlers struct {
GetRoomRPSChallenge http.HandlerFunc
HandleCallback http.HandlerFunc
HandleZGameCallback http.HandlerFunc
HandleAMGUser http.HandlerFunc
HandleAMGSettlement http.HandlerFunc
HandleHotgameGetUserInfo http.HandlerFunc
HandleHotgameUpdateBalance http.HandlerFunc
}
@ -761,5 +763,8 @@ func (r routes) registerGameRoutes() {
r.rootPublic("/getUserInfo", http.MethodPost, h.HandleHotgameGetUserInfo)
r.rootPublic("/updateBalance", http.MethodPost, h.HandleHotgameUpdateBalance)
r.public("/zgame/callback/{operation...}", http.MethodPost, h.HandleZGameCallback)
// AMG 后台只配置 api_url厂商会固定追加 /amg/user 与 /amg/settlement因此两条路径不能复用带 operation 的通用 POST 路由。
r.public("/amg/user", http.MethodGet, h.HandleAMGUser)
r.public("/amg/settlement", http.MethodPost, h.HandleAMGSettlement)
r.public("/game-callbacks/{platform_code}/{operation}", http.MethodPost, h.HandleCallback)
}

View File

@ -27,12 +27,17 @@ CREATE TABLE IF NOT EXISTS wallet_transactions (
request_hash VARCHAR(128) NOT NULL COMMENT '请求哈希值',
external_ref VARCHAR(128) NOT NULL DEFAULT '' COMMENT '外部引用',
metadata_json JSON NULL COMMENT '扩展元数据 JSON',
gift_sender_user_id BIGINT GENERATED ALWAYS AS (
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.sender_user_id')) AS SIGNED)
) VIRTUAL COMMENT '送礼人内部用户 ID仅用于 gift metadata 的索引检索',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, transaction_id),
UNIQUE KEY uk_wallet_tx_command (app_code, command_id),
KEY idx_wallet_tx_biz_time (app_code, biz_type, created_at_ms),
KEY idx_wallet_tx_biz_status_time (app_code, biz_type, status, created_at_ms),
KEY idx_wallet_tx_gift_record_page (app_code, biz_type, status, created_at_ms, transaction_id),
KEY idx_wallet_tx_gift_sender_page (app_code, gift_sender_user_id, biz_type, status, created_at_ms, transaction_id),
KEY idx_wallet_tx_external_ref (app_code, external_ref)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包交易主表';
@ -47,6 +52,40 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 送礼记录分页按 app/biz_type/status 过滤,并用 created_at_ms + transaction_id 做稳定倒序;
-- 把 transaction_id 放进索引末尾可直接覆盖同毫秒翻页边界,避免深页额外回表后再做不稳定排序。
-- 存量大表建索引会扫描 wallet_transactions 并产生额外 IO/磁盘占用,生产执行必须安排低峰窗口并先确认磁盘余量。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_transactions' AND INDEX_NAME = 'idx_wallet_tx_gift_record_page') = 0,
'ALTER TABLE wallet_transactions ADD INDEX idx_wallet_tx_gift_record_page (app_code, biz_type, status, created_at_ms, transaction_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 公共用户筛选先在 user owner 库解析内部 user_id再按 metadata 中冻结的 sender_user_id 检索钱包事实;
-- 虚拟生成列不复制业务事实,历史交易会自动可查,也避免后台分页对 JSON_EXTRACT 做全表扫描。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_transactions' AND COLUMN_NAME = 'gift_sender_user_id') = 0,
'ALTER TABLE wallet_transactions ADD COLUMN gift_sender_user_id BIGINT GENERATED ALWAYS AS (CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, ''$.sender_user_id'')) AS SIGNED)) VIRTUAL COMMENT ''送礼人内部用户 ID仅用于 gift metadata 的索引检索''',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 组合索引覆盖 app + 送礼人 + 场景/状态 + 稳定时间翻页;存量大表建索引仍会扫描 wallet_transactions
-- 上线执行前必须在低峰确认磁盘余量和在线 DDL 能力,执行中监控 IO、复制延迟与 metadata lock 等待。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_transactions' AND INDEX_NAME = 'idx_wallet_tx_gift_sender_page') = 0,
'ALTER TABLE wallet_transactions ADD INDEX idx_wallet_tx_gift_sender_page (app_code, gift_sender_user_id, biz_type, status, created_at_ms, transaction_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS wallet_entries (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '流水 ID',