2026-06-09 11:04:31 +08:00

955 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package game
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const defaultScene = gamedomain.SceneVoiceRoom
// Repository 隔离 game-service 持久化事实,订单和 session 不能只放内存。
type Repository interface {
Ping(ctx context.Context) error
ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error)
ListRecentGames(ctx context.Context, query ListRecentGamesQuery) ([]gamedomain.AppGame, error)
GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error)
CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error
GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error)
InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error
CreateRepairOrder(ctx context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error)
ClaimPendingLevelEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]gamedomain.LevelEventOutbox, error)
MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error
MarkLevelEventFailed(ctx context.Context, eventID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error)
UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error)
ListCatalog(ctx context.Context, query ListCatalogQuery) ([]gamedomain.CatalogItem, string, error)
UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error)
SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error)
DeleteCatalog(ctx context.Context, appCode string, gameID string) error
}
// WalletClient 是 game-service 调 wallet-service 的最小改账依赖。
type WalletClient interface {
ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
}
// UserClient 只暴露平台回调需要的用户展示资料查询能力。
type UserClient interface {
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
}
// ActivityClient 是 game-service 向等级系统投递游戏消耗事实的最小依赖。
type ActivityClient interface {
ConsumeLevelEvent(ctx context.Context, req *activityv1.ConsumeLevelEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error)
}
type Config struct {
LaunchSessionTTL time.Duration
}
type Service struct {
config Config
repository Repository
wallet WalletClient
user UserClient
activity ActivityClient
now func() time.Time
signatureNonceMu sync.Mutex
signatureNonces map[string]int64
}
type ListGamesQuery struct {
AppCode string
UserID int64
Scene string
RoomID string
RegionID int64
Language string
ClientPlatform string
}
type ListRecentGamesQuery struct {
AppCode string
UserID int64
Scene string
RoomID string
RegionID int64
Language string
ClientPlatform string
PageSize int32
}
type ListCatalogQuery struct {
AppCode string
PlatformCode string
Status string
PageSize int32
Cursor string
}
type LaunchCommand struct {
AppCode string
RequestID string
UserID int64
DisplayUserID string
GameID string
Scene string
RoomID string
RegionID int64
AccessToken string
}
type LaunchResult struct {
SessionID string
LaunchURL string
ExpiresAtMS int64
Orientation string
SafeHeight int32
ServerTimeMS int64
}
type BridgeScriptResult struct {
URL string
Version string
SHA256 string
ServerTimeMS int64
}
// New 创建 game-service 用例层。
func New(config Config, repository Repository, wallet WalletClient, user UserClient, activity ...ActivityClient) *Service {
if config.LaunchSessionTTL <= 0 {
config.LaunchSessionTTL = 15 * time.Minute
}
var activityClient ActivityClient
if len(activity) > 0 {
activityClient = activity[0]
}
return &Service{
config: config,
repository: repository,
wallet: wallet,
user: user,
activity: activityClient,
now: time.Now,
signatureNonces: make(map[string]int64),
}
}
// Repository 只给 transport/admin 薄封装复用;业务主链路仍通过 Service 方法表达。
func (s *Service) Repository() Repository {
if s == nil {
return nil
}
return s.repository
}
// ListGames 返回 App 语音房里的跨平台合并游戏列表。
func (s *Service) ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, int64, error) {
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
query.Scene = normalizeScene(query.Scene)
query.Language = strings.ToLower(strings.TrimSpace(query.Language))
query.ClientPlatform = strings.ToLower(strings.TrimSpace(query.ClientPlatform))
ctx = appcode.WithContext(ctx, query.AppCode)
games, err := s.repository.ListGames(ctx, query)
return games, s.now().UnixMilli(), err
}
// ListRecentGames 返回当前用户最近启动过的语音房游戏,供 App 的 My Games tab 直接展示。
func (s *Service) ListRecentGames(ctx context.Context, query ListRecentGamesQuery) ([]gamedomain.AppGame, int64, error) {
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
query.Scene = normalizeScene(query.Scene)
query.Language = strings.ToLower(strings.TrimSpace(query.Language))
query.ClientPlatform = strings.ToLower(strings.TrimSpace(query.ClientPlatform))
query.PageSize = normalizeRecentGamesPageSize(query.PageSize)
ctx = appcode.WithContext(ctx, query.AppCode)
// 最近常玩只信任 game_launch_sessions 的真实启动事实,同时在仓储层重新套当前展示规则;
// 这样旧游戏下架、维护、区域不可见后不会继续出现在 My Games tab。
games, err := s.repository.ListRecentGames(ctx, query)
return games, s.now().UnixMilli(), err
}
// GetBridgeScript 返回 App 通用游戏桥接脚本配置,供客户端启动预热和打开游戏面板时做轻量刷新。
func (s *Service) GetBridgeScript(ctx context.Context, appCode string) (BridgeScriptResult, error) {
if s.repository == nil {
return BridgeScriptResult{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
platforms, err := s.repository.ListPlatforms(ctx, appCode, gamedomain.StatusActive)
if err != nil {
return BridgeScriptResult{}, err
}
// admin 会把通用桥接脚本同步写入所有平台;这里仍按 sort/code 排序后读取第一份有效配置,
// 避免数据库返回顺序变化导致 App 在相同配置之间来回切换。
sort.SliceStable(platforms, func(left, right int) bool {
if platforms[left].SortOrder != platforms[right].SortOrder {
return platforms[left].SortOrder < platforms[right].SortOrder
}
return strings.Compare(platforms[left].PlatformCode, platforms[right].PlatformCode) < 0
})
for _, platform := range platforms {
config := bridgeScriptConfigFromPlatform(platform)
if strings.TrimSpace(config.URL) == "" {
continue
}
return BridgeScriptResult{
URL: strings.TrimSpace(config.URL),
Version: strings.TrimSpace(config.Version),
SHA256: strings.TrimSpace(config.SHA256),
ServerTimeMS: s.now().UnixMilli(),
}, nil
}
return BridgeScriptResult{ServerTimeMS: s.now().UnixMilli()}, nil
}
// LaunchGame 创建短期 H5 启动会话,并把原始钱包能力限制在服务端。
func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (LaunchResult, error) {
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" {
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "launch command is incomplete")
}
if s.repository == nil {
return LaunchResult{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.Scene = normalizeScene(command.Scene)
command.DisplayUserID = strings.TrimSpace(command.DisplayUserID)
if command.DisplayUserID == "" {
command.DisplayUserID = strconv.FormatInt(command.UserID, 10)
}
ctx = appcode.WithContext(ctx, command.AppCode)
game, err := s.repository.GetLaunchableGame(ctx, command.AppCode, strings.TrimSpace(command.GameID))
if err != nil {
return LaunchResult{}, err
}
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
}
regionID := command.RegionID
if regionID <= 0 {
var err error
regionID, err = s.resolveUserRegion(ctx, command.AppCode, command.RequestID, command.UserID)
if err != nil {
return LaunchResult{}, err
}
}
now := s.now()
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) {
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
}
token = "gt_" + randomHex(24)
}
sessionTTL := s.config.LaunchSessionTTL
// 三方游戏会在 H5 运行期间持续回调 token厂商适配器可以把默认 15 分钟启动 TTL 拉长。
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
sessionTTL = maxDuration(sessionTTL, leaderccConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
sessionTTL = maxDuration(sessionTTL, zeeoneConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
sessionTTL = maxDuration(sessionTTL, vivaGamesConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
sessionTTL = maxDuration(sessionTTL, reyouConfigFromPlatform(game).TokenTTL())
}
expiresAt := now.Add(sessionTTL).UnixMilli()
session := gamedomain.LaunchSession{
AppCode: command.AppCode,
SessionID: sessionID,
UserID: command.UserID,
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
RegionID: regionID,
Scene: command.Scene,
PlatformCode: game.PlatformCode,
GameID: game.GameID,
ProviderGameID: game.ProviderGameID,
// 明文 token 只返回给客户端和厂商,库里只保存 hash回调时通过 hash 查 session。
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: expiresAt,
CreatedAtMS: now.UnixMilli(),
UpdatedAtMS: now.UnixMilli(),
}
if err := s.repository.CreateLaunchSession(ctx, session); err != nil {
return LaunchResult{}, err
}
launchURL, err := buildLaunchURL(game, session, token)
if err != nil {
return LaunchResult{}, err
}
return LaunchResult{
SessionID: sessionID,
LaunchURL: launchURL,
ExpiresAtMS: expiresAt,
Orientation: game.Orientation,
SafeHeight: game.SafeHeight,
ServerTimeMS: now.UnixMilli(),
}, nil
}
// HandleCallback 保留 provider 原始请求摘要,并对 demo adapter 支持余额查询和金币改账。
func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) ([]byte, string, error) {
if req == nil || strings.TrimSpace(req.GetPlatformCode()) == "" || strings.TrimSpace(req.GetOperation()) == "" {
return nil, "", xerr.New(xerr.InvalidArgument, "callback request is incomplete")
}
if s.repository == nil {
return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
operation := strings.ToLower(strings.TrimSpace(req.GetOperation()))
// requestHash 是回调原文摘要,用于审计和钱包幂等校验,避免落库保存完整敏感 body。
requestHash := stableHashBytes(req.GetRawBody())
callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano()))
signatureValid := false
providerRequestID := ""
var responseBody []byte
var contentType string
var statusCode string
var err error
if platform, platformErr := s.repository.GetPlatform(ctx, app, req.GetPlatformCode()); platformErr == nil {
// adapter_type 是三方协议边界:新增厂商时只新增适配器,不改钱包/用户主流程。
switch {
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterYomiV4):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterLeaderCCV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleLeaderCCOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterZeeOneV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZeeOneOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterBaishunV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleBaishunOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterVivaGamesV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleVivaGamesOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterReyouV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleReyouOperation(ctx, app, platform, req, operation, requestHash)
default:
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
}
} else {
// 找不到平台时退回 demo保留旧测试入口行为避免管理后台未配置时直接打断联调。
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
}
responseHash := stableHashBytes(responseBody)
logStatus := "succeeded"
if err != nil {
logStatus = "failed"
responseBody, contentType = jsonResponse(map[string]any{"code": string(xerr.CodeOf(err)), "message": xerr.MessageOf(err)})
statusCode = string(xerr.CodeOf(err))
responseHash = stableHashBytes(responseBody)
}
_ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{
AppCode: app,
CallbackID: callbackID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
Operation: operation,
RequestID: req.GetMeta().GetRequestId(),
ProviderRequestID: providerRequestID,
SignatureValid: signatureValid,
RequestHash: requestHash,
ResponseCode: statusCode,
ResponseBodyHash: responseHash,
Status: logStatus,
CreatedAtMS: s.now().UnixMilli(),
})
// InsertCallbackLog 失败不影响厂商响应;回调主链路优先保证游戏侧不超时。
return responseBody, contentType, err
}
func (s *Service) handleDemoOperation(ctx context.Context, app string, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, error) {
switch operation {
case "get_user_info":
result, err := s.getCallbackUserInfo(ctx, app, req)
if err != nil {
return nil, "", "", err
}
raw, contentType := jsonResponse(result)
return raw, contentType, "OK", nil
case "get_balance":
if s.wallet == nil {
return nil, "", "", xerr.New(xerr.Unavailable, "wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
return nil, "", "", xerr.New(xerr.InvalidArgument, "invalid get_balance payload")
}
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
UserId: body.UserID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return nil, "", "", err
}
var balance int64
for _, item := range resp.GetBalances() {
if item.GetAssetType() == "COIN" {
balance = item.GetAvailableAmount()
}
}
raw, contentType := jsonResponse(map[string]any{"code": 0, "balance": balance})
return raw, contentType, "OK", nil
case "change_coin":
result, err := s.applyCallbackCoinChange(ctx, app, req, requestHash)
if err != nil {
return nil, "", "", err
}
raw, contentType := jsonResponse(result)
return raw, contentType, "OK", nil
default:
raw, contentType := jsonResponse(map[string]any{"code": 0, "message": "ok"})
return raw, contentType, "OK", nil
}
}
func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *gamev1.CallbackRequest) (map[string]any, error) {
if s.user == nil || s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "invalid get_user_info payload")
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: req.GetMeta().GetRequestId(),
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: app,
},
UserId: body.UserID,
})
if err != nil {
return nil, err
}
balanceResp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
UserId: body.UserID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return nil, err
}
var balance int64
for _, item := range balanceResp.GetBalances() {
if item.GetAssetType() == "COIN" {
balance = item.GetAvailableAmount()
break
}
}
user := userResp.GetUser()
return map[string]any{
"code": 0,
"user_id": body.UserID,
"display_user_id": user.GetDisplayUserId(),
"nickname": user.GetUsername(),
"avatar": user.GetAvatar(),
"country": user.GetCountry(),
"region_id": user.GetRegionId(),
"balance": balance,
}, nil
}
func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) {
if userID <= 0 || s.user == nil {
return 0, nil
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: requestID,
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: app,
},
UserId: userID,
})
if err != nil {
return 0, err
}
return userResp.GetUser().GetRegionId(), nil
}
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
if s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
GameID string `json:"game_id"`
ProviderGameID string `json:"provider_game_id"`
ProviderOrderID string `json:"provider_order_id"`
ProviderRoundID string `json:"provider_round_id"`
OpType string `json:"op_type"`
CoinAmount int64 `json:"coin_amount"`
RoomID string `json:"room_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil {
return nil, xerr.New(xerr.InvalidArgument, "invalid change_coin payload")
}
body.GameID = strings.TrimSpace(body.GameID)
body.ProviderOrderID = strings.TrimSpace(body.ProviderOrderID)
body.OpType = strings.TrimSpace(body.OpType)
if body.UserID <= 0 || body.GameID == "" || body.ProviderOrderID == "" || body.OpType == "" || body.CoinAmount <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "change_coin payload is incomplete")
}
if body.ProviderGameID == "" {
game, err := s.repository.GetLaunchableGame(ctx, app, body.GameID)
if err != nil {
return nil, err
}
body.ProviderGameID = game.ProviderGameID
}
regionID, err := s.resolveUserRegion(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
if err != nil {
return nil, err
}
order := gamedomain.GameOrder{
AppCode: app,
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
ProviderOrderID: body.ProviderOrderID,
ProviderRoundID: body.ProviderRoundID,
GameID: body.GameID,
ProviderGameID: body.ProviderGameID,
UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID),
RegionID: regionID,
OpType: body.OpType,
CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying,
RequestHash: requestHash,
}
order, exists, err := s.repository.CreateGameOrder(ctx, order)
if err != nil {
return nil, err
}
if exists && order.Status == gamedomain.OrderStatusSucceeded {
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": order.WalletTransactionID, "balance_after": order.WalletBalanceAfter, "idempotent_replay": true}, nil
}
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + body.ProviderOrderID,
UserId: body.UserID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
GameId: body.GameID,
ProviderOrderId: body.ProviderOrderID,
ProviderRoundId: body.ProviderRoundID,
OpType: body.OpType,
CoinAmount: body.CoinAmount,
RoomId: body.RoomID,
RequestHash: requestHash,
})
nowMs := s.now().UnixMilli()
if err != nil {
status := gamedomain.OrderStatusFailed
if xerr.IsCode(err, xerr.IdempotencyConflict) {
status = gamedomain.OrderStatusConflict
}
_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
return nil, err
}
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
return nil, err
}
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": walletResp.GetWalletTransactionId(), "balance_after": walletResp.GetBalanceAfter(), "idempotent_replay": walletResp.GetIdempotentReplay()}, nil
}
// ProcessLevelEventOutboxBatch 把成功游戏扣费订单异步投递给 activity-service 的游戏等级轨道。
func (s *Service) ProcessLevelEventOutboxBatch(ctx context.Context, runID string, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if s.repository == nil {
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
if s.activity == nil {
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "activity client is not configured")
}
if batchSize <= 0 {
batchSize = 100
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
if strings.TrimSpace(workerID) == "" {
workerID = "game-level-relay"
}
events, err := s.repository.ClaimPendingLevelEvents(ctx, workerID, s.now().UnixMilli(), lockTTL, batchSize)
if err != nil {
return 0, 0, 0, 0, false, err
}
for _, event := range events {
processed++
eventCtx := appcode.WithContext(ctx, event.AppCode)
resp, relayErr := s.activity.ConsumeLevelEvent(eventCtx, &activityv1.ConsumeLevelEventRequest{
Meta: &activityv1.RequestMeta{
RequestId: event.EventID,
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: event.AppCode,
},
EventId: event.EventID,
SourceEventId: event.OrderID,
SourceService: "game-service",
SourceEventType: "GameCoinDebited",
UserId: event.UserID,
Track: "game",
MetricType: "game_spend_coin",
ValueDelta: event.CoinAmount,
OccurredAtMs: event.OccurredAtMS,
DimensionsJson: event.PayloadJSON,
})
if relayErr != nil {
failure++
nextRetry := s.now().Add(time.Minute).UnixMilli()
_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, xerr.MessageOf(relayErr), nextRetry, s.now().UnixMilli())
continue
}
if resp.GetStatus() != "consumed" && resp.GetStatus() != "duplicate" {
failure++
nextRetry := s.now().Add(time.Minute).UnixMilli()
_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, "activity returned "+resp.GetStatus(), nextRetry, s.now().UnixMilli())
continue
}
if err := s.repository.MarkLevelEventDelivered(eventCtx, event.EventID, s.now().UnixMilli()); err != nil {
failure++
continue
}
success++
}
return int32(len(events)), processed, success, failure, len(events) == batchSize, nil
}
func normalizeScene(scene string) string {
scene = strings.ToLower(strings.TrimSpace(scene))
if scene == "" {
return defaultScene
}
return scene
}
func normalizeRecentGamesPageSize(pageSize int32) int32 {
if pageSize <= 0 {
return 4
}
if pageSize > 20 {
return 20
}
return pageSize
}
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
base := strings.TrimSpace(game.APIBaseURL)
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) && base == "" {
// 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。
return "", nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
// 百顺 H5 地址可能来自 one_game_info/download_url也支持后台按游戏覆盖避免按环境写死。
if configuredBase := baishunLaunchBaseURL(game, baishunConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
// VIVAGAMES 文档说明游戏 URL 由厂商提供;后台按 game_id 保存 URL启动时只拼客户端 getConfig 需要的热配置。
if configuredBase := vivaGamesLaunchBaseURL(game, vivaGamesConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
// 热游 H5 地址由厂商按游戏提供;后台配置 game_urls 后,上线新游戏无需改服务端配置文件。
if configuredBase := reyouLaunchBaseURL(game, reyouConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
}
if base == "" {
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
return "", xerr.New(xerr.InvalidArgument, "zeeone launch url is required")
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
return "", xerr.New(xerr.InvalidArgument, "baishun launch url is required")
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
return "", xerr.New(xerr.InvalidArgument, "vivagames launch url is required")
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
return "", xerr.New(xerr.InvalidArgument, "reyou launch url is required")
}
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
base = "https://game.example.local/h5"
}
parsed, err := url.Parse(base)
if err != nil {
return "", xerr.New(xerr.InvalidArgument, "platform launch url is invalid")
}
query := parsed.Query()
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
// Yomi 的启动地址是 auth/gateway用户合法性通过 platAuthCode 在服务端 gettoken 二次校验。
config := yomiConfigFromPlatform(game)
lang := config.DefaultLang
if lang == "" {
lang = "en"
}
query.Set("gameUid", session.ProviderGameID)
query.Set("platAuthCode", token)
query.Set("platUserId", externalUserID(session, config.UIDMode))
query.Set("lang", lang)
// Yomi gameLevelUid 语义类似房间/场次,没有后台配置时必须显式传 0。
if gameLevelUID := config.GameLevelUIDFor(game); gameLevelUID > 0 {
query.Set("gameLevelUid", strconv.FormatInt(gameLevelUID, 10))
} else {
query.Set("gameLevelUid", "0")
}
if config.PlatPayload != "" {
query.Set("platPayload", config.PlatPayload)
}
if session.RoomID != "" {
query.Set("platRoomId", session.RoomID)
}
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
// 灵仙启动只需要 uid/token/lang/roomid后续 userinfo/change_coin 通过 token + MD5 签名校验。
config := leaderccConfigFromPlatform(game)
lang := config.DefaultLang
if lang == "" {
lang = "en"
}
query.Set("uid", externalUserID(session, config.UIDMode))
query.Set("token", token)
query.Set("lang", lang)
if session.RoomID != "" {
query.Set("roomid", session.RoomID)
}
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
// ZeeOne 直接加载指定游戏 H5sessionId 使用 App access tokenmerchant/platform 来自后台热配置。
config := zeeoneConfigFromPlatform(game)
lang := config.DefaultLang
if lang == "" {
lang = "en"
}
query.Set("sessionId", token)
query.Set("language", lang)
if config.MerchantID > 0 {
query.Set("merchant", strconv.FormatInt(config.MerchantID, 10))
}
if config.PlatformID > 0 {
query.Set("platform", strconv.FormatInt(config.PlatformID, 10))
}
if session.RoomID != "" {
query.Set("sid", session.RoomID)
}
if config.GameMode != "" {
query.Set("gameMode", config.GameMode)
}
if config.Extra != "" {
query.Set("extra", config.Extra)
}
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
// 百顺前端规范以 NativeBridge.getConfig 为准;这里把同一份配置也拼到 URL方便 Flutter/H5 壳层读取。
config := baishunConfigFromPlatform(game)
query.Set("bridge_keys", "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl")
query.Set("appChannel", config.AppChannel)
if config.AppID > 0 {
query.Set("appId", strconv.FormatInt(config.AppID, 10))
}
query.Set("userId", externalUserID(session, config.UIDMode))
query.Set("code", token)
query.Set("isDataByUrl", "1")
if session.RoomID != "" {
query.Set("roomId", session.RoomID)
}
query.Set("gameMode", config.GameModeValue())
query.Set("language", config.LanguageValue())
query.Set("sceneMode", strconv.FormatInt(config.SceneMode, 10))
if config.CurrencyIcon != "" {
query.Set("currencyIcon", config.CurrencyIcon)
}
query.Set("gsp", strconv.FormatInt(config.GSP, 10))
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
// VIVAGAMES H5 通过 NativeBridge.getConfig 取 appId/userId/code这里把同一份配置拼到 URL供 App 壳层回写给 H5。
config := vivaGamesConfigFromPlatform(game)
query.Set("bridge_keys", "appId,userId,code,metadata,language,coinType,amountRate,bgmSwitch,gsp,currencyIcon,gameConfig,gameId")
if config.AppID > 0 {
query.Set("appId", strconv.FormatInt(config.AppID, 10))
}
query.Set("userId", externalUserID(session, config.UIDMode))
query.Set("code", token)
query.Set("gameId", strings.TrimSpace(session.ProviderGameID))
query.Set("metadata", config.MetadataValue(session))
query.Set("language", config.LanguageValue())
query.Set("coinType", strconv.FormatInt(config.CoinType, 10))
query.Set("amountRate", strconv.FormatInt(config.AmountRateValue(), 10))
query.Set("bgmSwitch", strconv.FormatInt(config.BGMSwitchValue(), 10))
query.Set("gsp", strconv.FormatInt(config.GSPValue(), 10))
if config.CurrencyIcon != "" {
query.Set("currencyIcon", config.CurrencyIcon)
query.Set("gameConfig", vivaGamesGameConfigQuery(config.CurrencyIcon))
}
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
// 热游文档只要求 uid/token/lang 三个启动参数;服务端回调再用 token 绑定用户、游戏和钱包订单。
config := reyouConfigFromPlatform(game)
query.Set("uid", externalUserID(session, config.UIDMode))
query.Set("token", token)
query.Set("lang", config.LanguageValue())
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)
query.Set("game_id", session.GameID)
query.Set("provider_game_id", session.ProviderGameID)
query.Set("platform_code", session.PlatformCode)
query.Set("user_id", strconv.FormatInt(session.UserID, 10))
query.Set("display_user_id", session.DisplayUserID)
query.Set("scene", session.Scene)
query.Set("room_id", session.RoomID)
appendBridgeScriptQuery(query, game)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func maxDuration(left time.Duration, right time.Duration) time.Duration {
if right > left {
return right
}
return left
}
func (s *Service) rememberSignatureNonce(scope string, nonce string, requestAt time.Time, ttl time.Duration) bool {
if s == nil {
return false
}
scope = strings.TrimSpace(scope)
nonce = strings.TrimSpace(nonce)
if scope == "" || nonce == "" {
return false
}
s.signatureNonceMu.Lock()
defer s.signatureNonceMu.Unlock()
if s.signatureNonces == nil {
s.signatureNonces = make(map[string]int64)
}
nowMS := s.now().UnixMilli()
for key, expiresAtMS := range s.signatureNonces {
if expiresAtMS <= nowMS {
delete(s.signatureNonces, key)
}
}
key := scope + ":" + nonce
if s.signatureNonces[key] > nowMS {
return false
}
s.signatureNonces[key] = requestAt.Add(ttl).UnixMilli()
return true
}
func jsonResponse(value any) ([]byte, string) {
body, err := json.Marshal(value)
if err != nil {
return []byte(`{"code":"INTERNAL_ERROR","message":"internal error"}`), "application/json"
}
return body, "application/json"
}
func randomHex(size int) string {
bytes := make([]byte, size)
if _, err := rand.Read(bytes); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(bytes)
}
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func stableHashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}