package game import ( "context" "crypto/aes" "crypto/cipher" "encoding/base64" "encoding/json" "net" "strconv" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) const ( // Yomi 返回码必须按厂商文档透传,游戏侧会直接根据这些数字决定是否重试或弹错。 yomiCodeOK = 200 yomiCodeSystemError = 9998 yomiCodeInsufficientBalance = 10003 yomiCodeGameMaintenance = 10004 yomiCodeTokenInvalid = 10005 yomiCodeSignatureInvalid = 10007 yomiCodeGameNotFound = 10018 yomiCodeUserNotFound = 10019 yomiCodeIPForbidden = 10020 yomiDefaultTokenTTL = 24 * time.Hour ) // yomiAdapterConfig 是后台 adapter_config_json 的结构化视图。 // 这类厂商字段必须放在 DB 配置里,后续切测试/正式环境或新增游戏档位无需重启服务。 type yomiAdapterConfig struct { // uid_mode 控制给厂商的用户标识来源: // 空或 user_id 使用内部数字 user_id;display_user_id 使用 App 展示 ID。 UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` // game_level_uid 对应 Yomi 房间/场次,没有配置时按文档传 0。 GameLevelUID int64 `json:"game_level_uid"` // game_level_uids 支持同一平台下不同游戏/房间使用不同场次 ID,key 可填 providerGameId 或内部 gameId。 GameLevelUIDs map[string]int64 `json:"game_level_uids"` // plat_payload 透传给 Yomi,适合联调时放环境、渠道或房间附加上下文;不参与服务端业务判断。 PlatPayload string `json:"plat_payload"` } func yomiConfigFromPlatform(value any) yomiAdapterConfig { // 启动链路拿到的是 LaunchableGame,回调链路拿到的是 Platform;这里统一解析,避免两处配置规则漂移。 var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config yomiAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.PlatPayload = strings.TrimSpace(config.PlatPayload) config.GameLevelUIDs = normalizeInt64Map(config.GameLevelUIDs) return config } func (c yomiAdapterConfig) TokenTTL() time.Duration { // Yomi 文档建议 token 至少 24 小时;后台可以调大或调小,但空配置必须有安全默认值。 if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return yomiDefaultTokenTTL } func (c yomiAdapterConfig) GameLevelUIDFor(game gamedomain.LaunchableGame) int64 { for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} { if value := c.GameLevelUIDs[strings.TrimSpace(key)]; value > 0 { return value } } return c.GameLevelUID } func normalizeInt64Map(input map[string]int64) map[string]int64 { if len(input) == 0 { return nil } output := make(map[string]int64, len(input)) for key, value := range input { key = strings.TrimSpace(key) if key != "" && value > 0 { output[key] = value } } if len(output) == 0 { return nil } return output } func externalUserID(session gamedomain.LaunchSession, uidMode string) string { // 三方通常会把 uid 展示给玩家或写入订单,切换 uid_mode 后必须保持启动和回调校验一致。 if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" { return strings.TrimSpace(session.DisplayUserID) } return strconv.FormatInt(session.UserID, 10) } func (s *Service) handleYomiOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { // IP 白名单先于解密执行,可以挡住明显非厂商来源的请求,减少密钥探测面。 if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{}) return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil } // Yomi 请求体是 JSON -> AES-GCM -> base64 URL 编码,body 解不开就按签名错误返回。 body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext) if err != nil { raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{}) return raw, contentType, strconv.Itoa(yomiCodeSignatureInvalid), false, "", nil } switch operation { // operation 来自网关路由尾段,不同部署可能保留 api/ 前缀,所以兼容常见写法。 case "gettoken", "get_token", "api/gettoken": return s.handleYomiGetToken(ctx, app, platform, req, body) case "userinfo", "user_info", "api/userinfo": return s.handleYomiUserInfo(ctx, app, platform, req, body) case "change_balance", "changebalance", "api/change_balance": return s.handleYomiChangeBalance(ctx, app, platform, req, body, requestHash) case "repair_order", "repairorder", "api/repair_order": return s.handleYomiRepairOrder(ctx, app, platform, body) default: raw, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{}) return raw, contentType, strconv.Itoa(yomiCodeOK), true, "", nil } } func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) { // gettoken 是 Yomi 用启动链接里的 platAuthCode 换平台 token 的闭环校验。 // 当前实现直接复用启动 token:Yomi 后续 userinfo/change_balance 都必须带同一个 platToken。 var body struct { GameUID json.Number `json:"gameUid"` Lang string `json:"lang"` PlatAuthCode string `json:"platAuthCode"` PlatUserID string `json:"platUserId"` PlatPayload string `json:"platPayload"` PlatRoomID string `json:"platRoomId"` } if err := decodeYomiJSON(raw, &body); err != nil { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } config := yomiConfigFromPlatform(platform) session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatAuthCode, body.GameUID.String()) // 同时校验 token、用户和游戏,避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "") } data := map[string]any{ // platUserId 必须原样返回,Yomi 文档明确要求保持业务含义稳定。 "platUserId": body.PlatUserID, "platUserOpenId": session.DisplayUserID, "nickname": snapshot.Nickname, "avatar": snapshot.Avatar, "balance": snapshot.Balance, "platToken": body.PlatAuthCode, "expireAt": session.ExpiresAtMS / 1000, } rawResp, contentType := yomiJSON(yomiCodeOK, "success", data) return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil } func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) { // userinfo 是游戏侧进入房间或中途刷新玩家资料时调用,只读用户资料和当前金币。 var body struct { PlatUserID string `json:"platUserId"` PlatToken string `json:"platToken"` PlatPayload string `json:"platPayload"` PlatRoomID string `json:"platRoomId"` } if err := decodeYomiJSON(raw, &body); err != nil { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } config := yomiConfigFromPlatform(platform) session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatToken, "") // userinfo 没有 gameUid 时只校验用户;change_balance 会额外校验游戏 ID。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "") } data := map[string]any{ "platUserId": body.PlatUserID, "platUserOpenId": session.DisplayUserID, "nickname": snapshot.Nickname, "avatar": snapshot.Avatar, "balance": snapshot.Balance, } rawResp, contentType := yomiJSON(yomiCodeOK, "success", data) return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil } func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) { // change_balance 是唯一真正改钱包的 Yomi 回调;requestId 是厂商幂等键,必须落库去重。 var body struct { RequestID json.Number `json:"requestId"` PlatUserID string `json:"platUserId"` PlatToken string `json:"platToken"` Gold int64 `json:"gold"` GameUID json.Number `json:"gameUid"` GameLevelUID json.Number `json:"gameLevelUid"` ReasonType int32 `json:"reasonType"` GameRoundID json.Number `json:"gameRoundId"` Timestamp int64 `json:"timestamp"` PlatPayload string `json:"platPayload"` PlatRoomID string `json:"platRoomId"` GameInfos []any `json:"game_infos"` } if err := decodeYomiJSON(raw, &body); err != nil { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } providerOrderID := strings.TrimSpace(body.RequestID.String()) config := yomiConfigFromPlatform(platform) session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatToken, body.GameUID.String()) // 改账必须绑定 token、uid、gameUid 三个维度,防止跨用户、跨游戏复用合法 token。 if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID) } opType := yomiOpType(body.ReasonType) // reasonType=1 扣款,reasonType=2 加款;gold=0 是免费局/未中奖的合法结算。 if providerOrderID == "" || opType == "" || body.Gold < 0 { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID) } result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash) if err != nil { // 厂商要求失败也返回当前余额,查不到时 bestEffortCoinBalance 会退化为 0。 code := yomiCodeFromError(err) message := yomiMessageFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) rawResp, contentType := yomiJSON(code, message, map[string]any{"balance": balance}) return rawResp, contentType, strconv.Itoa(code), true, providerOrderID, nil } rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{"balance": result.BalanceAfter}) return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil } func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) { // 补单接口首版只做简单校验和审计落库,不直接补钱,符合当前“先写 Log”边界。 var body struct { RequestID json.Number `json:"requestId"` OrderID string `json:"orderId"` PlatUserID string `json:"platUserId"` Gold int64 `json:"gold"` GameUID json.Number `json:"gameUid"` GameRoundID json.Number `json:"gameRoundId"` RewardType int32 `json:"rewardType"` WinID string `json:"winId"` PlatRoomID string `json:"platRoomId"` } if err := decodeYomiJSON(raw, &body); err != nil { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") } providerOrderID := strings.TrimSpace(body.OrderID) // 不同厂商/版本可能只给 requestId,这里把 orderId 和 requestId 都兼容成 providerOrderID。 if providerOrderID == "" { providerOrderID = strings.TrimSpace(body.RequestID.String()) } if providerOrderID == "" || strings.TrimSpace(body.PlatUserID) == "" || strings.TrimSpace(body.GameUID.String()) == "" || body.Gold < 0 { return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID) } _, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{ AppCode: app, RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID), PlatformCode: platform.PlatformCode, ProviderOrderID: providerOrderID, Reason: "yomi_repair_order", Status: gamedomain.RepairStatusLogged, }) if err != nil { return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, providerOrderID) } rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{}) return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil } type yomiCoinChangeResult struct { BalanceAfter int64 IdempotentReplay bool } func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) { // order_id 用平台订单号稳定派生,保证厂商重试、服务重启、并发请求都命中同一条订单。 order := gamedomain.GameOrder{ AppCode: app, OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID), PlatformCode: strings.TrimSpace(req.GetPlatformCode()), ProviderOrderID: providerOrderID, ProviderRoundID: providerRoundID, GameID: session.GameID, ProviderGameID: session.ProviderGameID, UserID: session.UserID, RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)), RegionID: session.RegionID, CountryID: session.CountryID, OpType: opType, CoinAmount: amount, Status: gamedomain.OrderStatusWalletApplying, RequestHash: requestHash, } order, exists, err := s.repository.CreateGameOrder(ctx, order) if err != nil { return yomiCoinChangeResult{}, err } if exists && order.Status == gamedomain.OrderStatusSucceeded { // 已成功订单按幂等重放处理,直接返回历史余额,不能再次调用钱包。 return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter, IdempotentReplay: true}, nil } if amount == 0 { // Yomi 文档允许投注/返奖金额为 0;这类订单仍需标记成功,但不能产生钱包流水。 balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil { return yomiCoinChangeResult{}, err } return yomiCoinChangeResult{BalanceAfter: balance}, nil } if s.wallet == nil { return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured") } // wallet-service 持有真实余额,game-service 只传入平台订单和幂等命令 ID。 walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ RequestId: req.GetMeta().GetRequestId(), AppCode: app, CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + providerOrderID, UserId: session.UserID, PlatformCode: strings.TrimSpace(req.GetPlatformCode()), GameId: session.GameID, ProviderOrderId: providerOrderID, ProviderRoundId: providerRoundID, OpType: opType, CoinAmount: amount, RoomId: firstNonEmpty(roomID, session.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 yomiCoinChangeResult{}, err } if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil { return yomiCoinChangeResult{}, err } return yomiCoinChangeResult{BalanceAfter: walletResp.GetBalanceAfter()}, nil } type yomiUserSnapshot struct { // 厂商只需要展示资料和金币,不把内部用户完整模型暴露给适配器。 Nickname string Avatar string Balance int64 } func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) { // 用户资料走 user-service,金币走 wallet-service,避免 game-service 缓存造成余额不准。 if s.user == nil || s.wallet == nil { return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured") } 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: userID, }) if err != nil { return yomiUserSnapshot{}, err } return yomiUserSnapshot{ Nickname: userResp.GetUser().GetUsername(), Avatar: userResp.GetUser().GetAvatar(), Balance: s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), userID), }, nil } func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 { // 余额查询失败时不阻断错误响应;厂商协议更需要一个可解析的 balance 字段。 if s.wallet == nil || userID <= 0 { return 0 } resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{ RequestId: requestID, AppCode: app, UserId: userID, AssetTypes: []string{"COIN"}, }) if err != nil { return 0 } for _, item := range resp.GetBalances() { if item.GetAssetType() == "COIN" { return item.GetAvailableAmount() } } return 0 } func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) { // 启动 token 明文只出现在启动 URL 和厂商回调里,数据库里按 hash 查询。 if strings.TrimSpace(token) == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } session, err := s.repository.GetLaunchSessionByToken(ctx, app, token) if err != nil { return gamedomain.LaunchSession{}, err } if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() { return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired") } return session, nil } func (s *Service) validScopedYomiSession(ctx context.Context, app string, platformCode string, token string, providerGameID string) (gamedomain.LaunchSession, error) { // 同一个 App token 可能同时启动多个厂商游戏,所以 Yomi 回调必须带平台和可选 gameUid 查询,不能只按 token 命中任意旧 session。 if strings.TrimSpace(token) == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } session, err := s.repository.GetLaunchSessionByTokenScope(ctx, app, token, platformCode, providerGameID) if err != nil { return gamedomain.LaunchSession{}, err } if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() { return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired") } return session, nil } func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool { // gameUID 允许为空是为了兼容 userinfo;只要厂商传了就必须和启动会话里的 provider_game_id 一致。 if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) { return false } expectedUserID := externalUserID(session, config.UIDMode) return strings.TrimSpace(platUserID) == expectedUserID } func yomiOpType(reasonType int32) string { // 转成钱包服务理解的 debit/credit,厂商枚举不向下游扩散。 switch reasonType { case 1: return "debit" case 2: return "credit" default: return "" } } func decodeYomiJSON(raw []byte, out any) error { // UseNumber 避免 requestId/gameRoundId 这类 64 位数字经过 float64 丢精度。 decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.UseNumber() return decoder.Decode(out) } func decryptYomiBody(raw []byte, secret string) ([]byte, error) { // 文档给的 AppSecret 是 32 字节字符串,正好对应 AES-256 key。 secret = strings.TrimSpace(secret) if secret == "" { return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty") } decoded, err := decodeYomiBase64(strings.TrimSpace(string(raw))) if err != nil { return nil, err } block, err := aes.NewCipher([]byte(secret)) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } if len(decoded) <= gcm.NonceSize() { return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short") } // Yomi 把 IV/nonce 放在密文前 12 位,剩余部分是 GCM ciphertext+tag。 nonce := decoded[:gcm.NonceSize()] ciphertext := decoded[gcm.NonceSize():] return gcm.Open(nil, nonce, ciphertext, nil) } func decodeYomiBase64(value string) ([]byte, error) { // 文档写 URL base64,但测试工具经常混用 padded/raw/std,这里按宽松解析提升联调成功率。 encodings := []*base64.Encoding{ base64.URLEncoding, base64.RawURLEncoding, base64.StdEncoding, base64.RawStdEncoding, } var lastErr error for _, encoding := range encodings { decoded, err := encoding.DecodeString(value) if err == nil { return decoded, nil } lastErr = err } return nil, lastErr } func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool { // 空白名单表示不限制;配置后支持单 IP 和 CIDR,便于同时兼容测试环境和正式机房。 if len(whitelist) == 0 { return true } ip := net.ParseIP(callbackRequestIP(req)) if ip == nil { return false } for _, raw := range whitelist { value := strings.TrimSpace(raw) if value == "" { continue } if _, network, err := net.ParseCIDR(value); err == nil && network.Contains(ip) { return true } if allowed := net.ParseIP(value); allowed != nil && allowed.Equal(ip) { return true } } return false } func callbackRequestIP(req *gamev1.CallbackRequest) string { // 网关会把真实来源 IP 写在转发头里;优先取第一段 X-Forwarded-For。 for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} { if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" { return strings.TrimSpace(strings.Split(raw, ",")[0]) } } for _, key := range []string{"X-Real-IP", "x-real-ip"} { if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" { return raw } } host, _, err := net.SplitHostPort(strings.TrimSpace(req.GetRemoteAddr())) if err == nil { return host } return strings.TrimSpace(req.GetRemoteAddr()) } func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { // 返回值同时服务“HTTP 响应”和“回调日志”,所以这里保留签名状态和平台请求号。 raw, contentType := yomiJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func yomiJSON(code int, message string, data any) ([]byte, string) { // Yomi 成功 message 固定 success;失败 message 给具体错误原因,data 必须始终是对象。 if strings.TrimSpace(message) == "" { message = "success" } if data == nil { data = map[string]any{} } return jsonResponse(map[string]any{"code": code, "message": message, "data": data}) } func yomiCodeFromError(err error) int { // 内部错误码在适配器边界转换成厂商错误码,避免污染业务服务的错误模型。 switch { case err == nil: return yomiCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return yomiCodeInsufficientBalance case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized): return yomiCodeTokenInvalid case xerr.IsCode(err, xerr.NotFound): return yomiCodeUserNotFound case xerr.IsCode(err, xerr.Conflict), xerr.IsCode(err, xerr.IdempotencyConflict): return yomiCodeSystemError default: return yomiCodeSystemError } } func yomiMessageFromError(err error) string { switch yomiCodeFromError(err) { case yomiCodeInsufficientBalance: return "gold not enough" case yomiCodeTokenInvalid: return "token invalid" case yomiCodeUserNotFound: return "user not found" default: if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { return xerr.MessageOf(err) } return "system error" } } func firstNonEmpty(values ...string) string { // roomId 这类字段优先使用回调里的实时值,缺失时回退启动会话里的值。 for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }