送礼记录
This commit is contained in:
parent
449110aa95
commit
d164dd5fd7
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_audit;statistics-service 只保留新增/DAU 口径。
|
||||
// 版本分布直接查询 login_audit 且只返回有最近登录版本的自然用户;statistics-service 只保留新增/DAU 口径。
|
||||
if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] {
|
||||
t.Fatalf("statistics calls mismatch: %+v", requested)
|
||||
}
|
||||
|
||||
@ -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 文档未提供游戏列表 API;game_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))
|
||||
|
||||
@ -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",
|
||||
|
||||
30
server/admin/internal/modules/giftrecord/dto.go
Normal file
30
server/admin/internal/modules/giftrecord/dto.go
Normal 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"`
|
||||
}
|
||||
87
server/admin/internal/modules/giftrecord/handler.go
Normal file
87
server/admin/internal/modules/giftrecord/handler.go
Normal 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 ""
|
||||
}
|
||||
41
server/admin/internal/modules/giftrecord/request.go
Normal file
41
server/admin/internal/modules/giftrecord/request.go
Normal 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
|
||||
}
|
||||
14
server/admin/internal/modules/giftrecord/routes.go
Normal file
14
server/admin/internal/modules/giftrecord/routes.go
Normal 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)
|
||||
}
|
||||
349
server/admin/internal/modules/giftrecord/service.go
Normal file
349
server/admin/internal/modules/giftrecord/service.go
Normal 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 ""
|
||||
}
|
||||
122
server/admin/internal/modules/giftrecord/service_test.go
Normal file
122
server/admin/internal/modules/giftrecord/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
37
server/admin/migrations/095_gift_record_navigation.sql
Normal file
37
server/admin/migrations/095_gift_record_navigation.sql
Normal 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'
|
||||
);
|
||||
@ -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"
|
||||
|
||||
412
services/game-service/internal/service/game/amg_adapter.go
Normal file
412
services/game-service/internal/service/game/amg_adapter.go
Normal 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})
|
||||
}
|
||||
232
services/game-service/internal/service/game/amg_adapter_test.go
Normal file
232
services/game-service/internal/service/game/amg_adapter_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 原文而不是 Bearer;gateway 仍需验签后从 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 {
|
||||
|
||||
@ -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 且没有 body;Authorization 中的游戏 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-service;gateway 只保留厂商原始 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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user