package game import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "strconv" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) const ( // 热游文档只定义这几个业务码;HTTP 200 仅表示回调已被接收,游戏侧以 errorCode 判定业务结果。 reyouCodeOK = 0 reyouCodeTokenInvalid = 1001 reyouCodeInsufficientBalance = 2001 reyouCodeDuplicatedOrder = 3001 reyouDefaultTokenTTL = 24 * time.Hour ) type reyouAdapterConfig struct { // uid_mode 必须同时约束启动 URL 和回调校验,避免同一 token 被不同用户标识复用。 UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` // 热游文档没有提供游戏列表 API,后台用 game_urls 保存厂商给的逐游戏 H5 地址。 GameURLs map[string]string `json:"game_urls"` LaunchURLTemplate string `json:"launch_url_template"` } func reyouConfigFromPlatform(value any) reyouAdapterConfig { // 启动链路拿 LaunchableGame,回调链路拿 Platform;两者共用一套 JSON 字段,避免配置漂移。 var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config reyouAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate) config.GameURLs = normalizeStringMap(config.GameURLs) return config } func (c reyouAdapterConfig) TokenTTL() time.Duration { // 热游要求 token 在游戏中以及延迟结算后至少 3 分钟仍有效;默认 24 小时覆盖长局和重试场景。 if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return reyouDefaultTokenTTL } func (c reyouAdapterConfig) LanguageValue() string { if c.DefaultLang != "" { return c.DefaultLang } return "en" } func reyouLaunchBaseURL(game gamedomain.LaunchableGame, config reyouAdapterConfig) string { for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} { if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" { return raw } } if config.LaunchURLTemplate == "" { return "" } replacer := strings.NewReplacer( "{provider_game_id}", strings.TrimSpace(game.ProviderGameID), "{providerGameId}", strings.TrimSpace(game.ProviderGameID), "{game_id}", strings.TrimSpace(game.GameID), "{gameId}", strings.TrimSpace(game.GameID), ) return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate)) } func (s *Service) handleReyouOperation(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) { return reyouAdapterError(reyouCodeTokenInvalid, "ip restricted", false, "") } switch operation { case "getuserinfo", "get_user_info", "get-user-info", "userinfo", "user_info": return s.handleReyouUserInfo(ctx, app, platform, req) case "updatebalance", "update_balance", "update-balance", "change_balance": return s.handleReyouUpdateBalance(ctx, app, platform, req, requestHash) default: raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{}) return raw, contentType, strconv.Itoa(reyouCodeOK), false, "", nil } } type reyouUserInfoBody struct { GameID json.RawMessage `json:"gameId"` UID json.RawMessage `json:"uid"` Token json.RawMessage `json:"token"` Sign string `json:"sign"` } func (body reyouUserInfoBody) gameIDText() string { return reyouJSONText(body.GameID) } func (body reyouUserInfoBody) uidText() string { return reyouJSONText(body.UID) } func (body reyouUserInfoBody) tokenText() string { return reyouJSONText(body.Token) } type reyouUpdateBalanceBody struct { OrderID json.RawMessage `json:"orderId"` GameID json.RawMessage `json:"gameId"` RoundID json.RawMessage `json:"roundId"` UID json.RawMessage `json:"uid"` Coin json.RawMessage `json:"coin"` Type json.RawMessage `json:"type"` Token json.RawMessage `json:"token"` Sign string `json:"sign"` } func (body reyouUpdateBalanceBody) orderIDText() string { return reyouJSONText(body.OrderID) } func (body reyouUpdateBalanceBody) gameIDText() string { return reyouJSONText(body.GameID) } func (body reyouUpdateBalanceBody) roundIDText() string { return reyouJSONText(body.RoundID) } func (body reyouUpdateBalanceBody) uidText() string { return reyouJSONText(body.UID) } func (body reyouUpdateBalanceBody) coinText() string { return reyouJSONText(body.Coin) } func (body reyouUpdateBalanceBody) typeText() string { return reyouJSONText(body.Type) } func (body reyouUpdateBalanceBody) tokenText() string { return reyouJSONText(body.Token) } func (s *Service) handleReyouUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { var body reyouUserInfoBody if err := decodeReyouJSON(req.GetRawBody(), &body); err != nil { return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", false, "") } gameID := body.gameIDText() uid := body.uidText() token := body.tokenText() // 文档签名顺序固定为 gameId + uid + token + key;不能排序,也不能插入分隔符。 if !reyouSignValid(body.Sign, platform.CallbackSecretCiphertext, gameID, uid, token) { return reyouAdapterError(reyouCodeTokenInvalid, "signature invalid", false, "") } config := reyouConfigFromPlatform(platform) session, err := s.validReyouSession(ctx, app, token) // token 通过后继续绑定 uid/gameId,防止同一登录 token 被跨用户或跨游戏复用。 if err != nil || !reyouSessionMatches(session, config, uid, gameID) { return reyouAdapterError(reyouCodeTokenInvalid, "token invalid", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return reyouAdapterError(reyouCodeFromError(err), reyouMessageFromError(err), true, "") } raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{ "uid": uid, "nickname": snapshot.Nickname, "avatar": snapshot.Avatar, "coin": snapshot.Balance, "vipLevel": int64(0), }) return raw, contentType, strconv.Itoa(reyouCodeOK), true, "", nil } func (s *Service) handleReyouUpdateBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) { var body reyouUpdateBalanceBody if err := decodeReyouJSON(req.GetRawBody(), &body); err != nil { return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", false, "") } providerOrderID := body.orderIDText() gameID := body.gameIDText() roundID := body.roundIDText() uid := body.uidText() coinText := body.coinText() typeText := body.typeText() token := body.tokenText() // 文档签名顺序固定为 orderId + gameId + roundId + uid + coin + type + token + key。 if !reyouSignValid(body.Sign, platform.CallbackSecretCiphertext, providerOrderID, gameID, roundID, uid, coinText, typeText, token) { return reyouAdapterError(reyouCodeTokenInvalid, "signature invalid", false, providerOrderID) } coin, coinOK := reyouInt64(coinText) opValue, typeOK := reyouInt32(typeText) opType := reyouOpType(opValue) if providerOrderID == "" || gameID == "" || uid == "" || token == "" || !coinOK || !typeOK || opType == "" || coin < 0 { return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", true, providerOrderID) } config := reyouConfigFromPlatform(platform) session, err := s.validReyouSession(ctx, app, token) // 热游同时带 token、uid 和 gameId,三者必须落到同一个启动会话;JWT 直连时允许 gameId 后置补齐。 if err != nil || !reyouSessionMatches(session, config, uid, gameID) { return reyouAdapterError(reyouCodeTokenInvalid, "token invalid", true, providerOrderID) } if session.ProviderGameID == "" { session.ProviderGameID = gameID } if session.GameID == "" { session.GameID = gameID } result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, roundID, opType, coin, "", requestHash) if err != nil { code := reyouCodeFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) raw, contentType := reyouJSON(code, reyouMessageFromError(err), map[string]any{"coin": balance, "responseId": ""}) return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil } if result.IdempotentReplay { // 热游文档要求重复 orderId 明确返回 3001;这里只回历史余额,不再次触发钱包改账。 raw, contentType := reyouJSON(reyouCodeDuplicatedOrder, "duplicated order", map[string]any{"coin": result.BalanceAfter, "responseId": reyouResponseID(app, req.GetPlatformCode(), providerOrderID)}) return raw, contentType, strconv.Itoa(reyouCodeDuplicatedOrder), true, providerOrderID, nil } raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{"coin": result.BalanceAfter, "responseId": reyouResponseID(app, req.GetPlatformCode(), providerOrderID)}) return raw, contentType, strconv.Itoa(reyouCodeOK), true, providerOrderID, nil } func (s *Service) validReyouSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) { token = strings.TrimSpace(token) if token == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } // App 当前把 access token 作为三方 token;JWT 要优先解析,避免旧 launch session 同 hash 但旧 gameId 误判。 if strings.Count(token, ".") == 2 { if session, err := s.appSessionFromAccessToken(app, token); err == nil { return session, nil } } if session, err := s.validYomiSession(ctx, app, token); err == nil { return session, nil } if strings.Count(token, ".") == 2 { return s.appSessionFromAccessToken(app, token) } return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } func reyouSessionMatches(session gamedomain.LaunchSession, config reyouAdapterConfig, uid string, gameID string) bool { if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) { return false } uid = strings.TrimSpace(uid) return uid == externalUserID(session, config.UIDMode) || uid == strings.TrimSpace(session.DisplayUserID) || uid == strconv.FormatInt(session.UserID, 10) } func reyouOpType(value int32) string { // 热游 type=1 表示扣减,type=2 表示增加;下游钱包只接受 debit/credit。 switch value { case 1: return "debit" case 2: return "credit" default: return "" } } func decodeReyouJSON(raw []byte, out any) error { decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.UseNumber() return decoder.Decode(out) } func reyouJSONText(raw json.RawMessage) string { text := strings.TrimSpace(string(raw)) if text == "" || text == "null" { return "" } var decoded string if err := json.Unmarshal(raw, &decoded); err == nil { return strings.TrimSpace(decoded) } return text } func reyouInt64(value string) (int64, bool) { parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return parsed, err == nil } func reyouInt32(value string) (int32, bool) { parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 32) return int32(parsed), err == nil } func reyouSignValid(actual string, key string, parts ...string) bool { key = strings.TrimSpace(key) if key == "" || strings.TrimSpace(actual) == "" { return false } builder := strings.Builder{} for _, part := range parts { builder.WriteString(strings.TrimSpace(part)) } builder.WriteString(key) sum := md5.Sum([]byte(builder.String())) expected := strings.ToUpper(hex.EncodeToString(sum[:])) return strings.EqualFold(strings.TrimSpace(actual), expected) } func reyouResponseID(app string, platformCode string, providerOrderID string) string { return "reyou_" + stableHash(app+"|"+platformCode+"|"+providerOrderID) } func reyouAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { raw, contentType := reyouJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func reyouJSON(errorCode int, message string, data any) ([]byte, string) { payload := map[string]any{"errorCode": errorCode} if data != nil { payload["data"] = data } if strings.TrimSpace(message) != "" { payload["message"] = strings.TrimSpace(message) } return jsonResponse(payload) } func reyouCodeFromError(err error) int { switch { case err == nil: return reyouCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return reyouCodeInsufficientBalance case xerr.IsCode(err, xerr.IdempotencyConflict): return reyouCodeDuplicatedOrder default: return reyouCodeTokenInvalid } } func reyouMessageFromError(err error) string { switch reyouCodeFromError(err) { case reyouCodeInsufficientBalance: return "coin not enough" case reyouCodeDuplicatedOrder: return "duplicated order" default: if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { return xerr.MessageOf(err) } return "token invalid" } }