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 直接加载指定游戏 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 } 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[:]) }