932 lines
36 KiB
Go
932 lines
36 KiB
Go
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) {
|
||
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())
|
||
}
|
||
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)
|
||
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 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")
|
||
}
|
||
// 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 直接加载指定游戏 H5,sessionId 使用 App access token,merchant/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
|
||
}
|
||
// 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[:])
|
||
}
|