package game import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "strconv" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) const ( // ZeeOne 文档定义 code=0 成功;HTTP 200 不代表业务成功,游戏侧读取 JSON code。 zeeoneCodeOK = 0 zeeoneCodeInvalidArgument = 1 zeeoneCodeSessionNotFound = 2 zeeoneCodeSignatureInvalid = 3 zeeoneCodeSessionInvalid = 5 zeeoneCodeInsufficientBalance = 6 zeeoneDefaultTokenTTL = 24 * time.Hour zeeoneSignatureValidWindow = 15 * time.Second zeeoneDefaultSuccessMessage = "succeed" ) type zeeoneAdapterConfig struct { // merchant_id/platform_id 来自 ZeeOne 商户后台;启动 URL 和 appId 校验都依赖这两个值。 MerchantID int64 `json:"merchant_id"` PlatformID int64 `json:"platform_id"` PlatformIDAlias int64 `json:"platform"` AppID string `json:"app_id"` UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` GameMode string `json:"game_mode"` Extra string `json:"extra"` // game_urls 解决同一个 ZeeOne 商户下每款游戏 H5 域名/路径不同的问题;后台改 JSON 后下一次启动即生效。 GameURLs map[string]string `json:"game_urls"` // launch_url_template 适合 ZeeOne 后续按固定路径批量上新,支持 {provider_game_id}/{providerGameId}/{game_id}/{gameId} 占位。 LaunchURLTemplate string `json:"launch_url_template"` } func zeeoneConfigFromPlatform(value any) zeeoneAdapterConfig { var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config zeeoneAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) if config.PlatformID == 0 { config.PlatformID = config.PlatformIDAlias } config.AppID = strings.TrimSpace(config.AppID) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.GameMode = strings.TrimSpace(config.GameMode) config.Extra = strings.TrimSpace(config.Extra) config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate) config.GameURLs = normalizeStringMap(config.GameURLs) return config } func (c zeeoneAdapterConfig) TokenTTL() time.Duration { if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return zeeoneDefaultTokenTTL } func (c zeeoneAdapterConfig) ExpectedAppID() string { if c.AppID != "" { return c.AppID } if c.MerchantID > 0 && c.PlatformID > 0 { return fmt.Sprintf("M%d_P%d", c.MerchantID, c.PlatformID) } return "" } func zeeoneLaunchBaseURL(game gamedomain.LaunchableGame, config zeeoneAdapterConfig) 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 normalizeStringMap(input map[string]string) map[string]string { if len(input) == 0 { return nil } output := make(map[string]string, len(input)) for key, value := range input { key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key != "" && value != "" { output[key] = value } } if len(output) == 0 { return nil } return output } func (s *Service) handleZeeOneOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { config := zeeoneConfigFromPlatform(platform) body, signed, decoded, providerRequestID := s.decodeZeeOneSignedBody(platform, req) if !signed { raw, contentType := zeeoneJSON(zeeoneCodeSignatureInvalid, "signature invalid", map[string]any{}) return raw, contentType, strconv.Itoa(zeeoneCodeSignatureInvalid), false, providerRequestID, nil } if !decoded { raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid argument", map[string]any{}) return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil } if err := validateZeeOneAppID(config, body.AppID); err != nil { raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid appId", map[string]any{}) return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil } switch operation { case "get_user_info", "userinfo", "user_info": return s.handleZeeOneUserInfo(ctx, app, config, req, body) case "change_balance", "change_coin", "update_coin": return s.handleZeeOneChangeBalance(ctx, app, config, req, body, requestHash) case "update_session", "refresh_session": return s.handleZeeOneUpdateSession(ctx, app, req.GetPlatformCode(), config, body) case "repair_order", "repair": return s.handleZeeOneRepairOrder(ctx, app, platform, body) default: raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{}) return raw, contentType, strconv.Itoa(zeeoneCodeOK), signed, providerRequestID, nil } } type zeeoneSignedEnvelope struct { ReqBody string `json:"reqbody"` Signature string `json:"signature"` SignatureNonce string `json:"signature_nonce"` Timestamp int64 `json:"timestamp"` } type zeeoneBusinessBody struct { AppID string `json:"appId"` UserID string `json:"userId"` SessionID string `json:"sessionId"` Extra string `json:"extra"` CurrencyDiff int64 `json:"currency_diff"` DiffMsg string `json:"diff_msg"` GameID int64 `json:"game_id"` RoomID string `json:"room_id"` ChangeTimeAt int64 `json:"change_time_at"` OrderID string `json:"order_id"` Extend string `json:"extend"` } func (s *Service) decodeZeeOneSignedBody(platform gamedomain.Platform, req *gamev1.CallbackRequest) (zeeoneBusinessBody, bool, bool, string) { var envelope zeeoneSignedEnvelope if err := json.Unmarshal(req.GetRawBody(), &envelope); err != nil { return zeeoneBusinessBody{}, false, false, "" } envelope.ReqBody = strings.TrimSpace(envelope.ReqBody) providerRequestID := zeeoneProviderRequestID(envelope.ReqBody) if !s.zeeoneSignatureValid(envelope, platform.CallbackSecretCiphertext) { return zeeoneBusinessBody{}, false, false, providerRequestID } var body zeeoneBusinessBody decoder := json.NewDecoder(strings.NewReader(envelope.ReqBody)) decoder.UseNumber() if err := decoder.Decode(&body); err != nil { return zeeoneBusinessBody{}, false, true, providerRequestID } body.normalize() return body, true, true, firstNonEmpty(body.OrderID, providerRequestID) } func (s *Service) zeeoneSignatureValid(envelope zeeoneSignedEnvelope, secret string) bool { secret = strings.TrimSpace(secret) if secret == "" || envelope.ReqBody == "" || strings.TrimSpace(envelope.SignatureNonce) == "" || envelope.Timestamp <= 0 { return false } now := s.now() requestAt := time.UnixMilli(envelope.Timestamp) if requestAt.After(now.Add(zeeoneSignatureValidWindow)) || now.Sub(requestAt) > zeeoneSignatureValidWindow { return false } raw := fmt.Sprintf("%s%s%d%s", strings.TrimSpace(envelope.SignatureNonce), envelope.ReqBody, envelope.Timestamp, secret) sum := md5.Sum([]byte(raw)) expected := hex.EncodeToString(sum[:]) return strings.EqualFold(strings.TrimSpace(envelope.Signature), expected) } func (body *zeeoneBusinessBody) normalize() { body.AppID = strings.TrimSpace(body.AppID) body.UserID = strings.TrimSpace(body.UserID) body.SessionID = strings.TrimSpace(body.SessionID) body.Extra = strings.TrimSpace(body.Extra) body.DiffMsg = strings.ToLower(strings.TrimSpace(body.DiffMsg)) body.RoomID = strings.TrimSpace(body.RoomID) body.OrderID = strings.TrimSpace(body.OrderID) body.Extend = strings.TrimSpace(body.Extend) } func validateZeeOneAppID(config zeeoneAdapterConfig, appID string) error { expected := config.ExpectedAppID() if expected == "" || strings.TrimSpace(appID) == "" { return nil } if strings.TrimSpace(appID) != expected { return xerr.New(xerr.InvalidArgument, "appId mismatch") } return nil } func (s *Service) handleZeeOneUserInfo(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) { session, err := s.validZeeOneSession(ctx, app, req.GetPlatformCode(), body.SessionID, body.GameID) if err != nil { return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "") } if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) { return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "") } data := map[string]any{ "userId": firstNonEmpty(body.UserID, externalUserID(session, config.UIDMode)), "name": snapshot.Nickname, "avatar": snapshot.Avatar, "balance": snapshot.Balance, "sessionId": body.SessionID, "expireDate": session.ExpiresAtMS, } if body.Extra != "" { data["extra"] = body.Extra } raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, data) return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil } func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody, requestHash string) ([]byte, string, string, bool, string, error) { if body.OrderID == "" || body.UserID == "" || body.SessionID == "" || body.GameID <= 0 || body.DiffMsg == "" { return zeeoneAdapterError(zeeoneCodeInvalidArgument, "invalid argument", true, body.OrderID) } session, err := s.validZeeOneSession(ctx, app, req.GetPlatformCode(), body.SessionID, body.GameID) if err != nil { return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID) } if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) { return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, body.OrderID) } if session.ProviderGameID == "" { session.ProviderGameID = strconv.FormatInt(body.GameID, 10) } if session.GameID == "" { session.GameID = session.ProviderGameID } opType, amount := zeeoneOpType(body.CurrencyDiff) providerOrderID := zeeoneCoinChangeProviderOrderID(body.OrderID, opType) result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash) if err != nil { code := zeeoneCodeFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) raw, contentType := zeeoneJSON(code, zeeoneMessageFromError(err), map[string]any{"currency_balance": balance}) return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil } raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{"currency_balance": result.BalanceAfter}) return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil } func (s *Service) handleZeeOneUpdateSession(ctx context.Context, app string, platformCode string, config zeeoneAdapterConfig, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) { session, err := s.validZeeOneSession(ctx, app, platformCode, body.SessionID, body.GameID) if err != nil { return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "") } if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) { return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "") } raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{ "sessionId": body.SessionID, "expireDate": session.ExpiresAtMS, }) return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil } func (s *Service) handleZeeOneRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) { if body.OrderID != "" { _, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{ AppCode: app, RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+body.OrderID), PlatformCode: platform.PlatformCode, ProviderOrderID: body.OrderID, Reason: "zeeone_repair_order", Status: gamedomain.RepairStatusLogged, }) if err != nil { return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID) } } raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{}) return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil } func (s *Service) validZeeOneSession(ctx context.Context, app string, platformCode string, sessionID string, gameID int64) (gamedomain.LaunchSession, error) { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found") } // ZeeOne 约定 sessionId 直接使用 App access token;同一个登录 token 可能启动多款游戏。 // 因此 JWT 必须优先解析,否则会先命中 game_launch_sessions 里旧游戏的 hash,导致 game_id 校验误判。 if strings.Count(sessionID, ".") == 2 { if session, err := s.appSessionFromAccessToken(app, sessionID); err == nil { return session, nil } } providerGameID := "" if gameID > 0 { providerGameID = strconv.FormatInt(gameID, 10) } // App access token 过期后仍可能处于三方游戏局中;启动链路已经把同一 token 的厂商、游戏和 24h TTL // 落到 game_launch_sessions。这里必须按平台和游戏收窄,避免同一 access token 打开多款 ZeeOne 游戏时拿到旧会话。 if session, err := s.validScopedYomiSession(ctx, app, platformCode, sessionID, providerGameID); err == nil { return session, nil } if strings.Count(sessionID, ".") == 2 { return s.appSessionFromAccessToken(app, sessionID) } return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found") } func zeeoneSessionMatches(session gamedomain.LaunchSession, config zeeoneAdapterConfig, userID string, gameID int64) bool { if gameID > 0 && strings.TrimSpace(session.ProviderGameID) != "" && strconv.FormatInt(gameID, 10) != strings.TrimSpace(session.ProviderGameID) { return false } userID = strings.TrimSpace(userID) return userID == "" || userID == externalUserID(session, config.UIDMode) || userID == strings.TrimSpace(session.DisplayUserID) || userID == strconv.FormatInt(session.UserID, 10) } func zeeoneOpType(diff int64) (string, int64) { if diff < 0 { return "debit", -diff } return "credit", diff } func zeeoneCoinChangeProviderOrderID(orderID string, opType string) string { orderID = strings.TrimSpace(orderID) opType = strings.ToLower(strings.TrimSpace(opType)) if orderID == "" || opType == "" { return orderID } return orderID + ":" + opType } func zeeoneProviderRequestID(reqBody string) string { var body struct { OrderID string `json:"order_id"` } _ = json.Unmarshal([]byte(reqBody), &body) return strings.TrimSpace(body.OrderID) } func zeeoneAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { raw, contentType := zeeoneJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func zeeoneJSON(code int, message string, data any) ([]byte, string) { if strings.TrimSpace(message) == "" && code == zeeoneCodeOK { message = zeeoneDefaultSuccessMessage } payload := map[string]any{"code": code, "message": strings.TrimSpace(message)} if data != nil { payload["data"] = data } return jsonResponse(payload) } func zeeoneCodeFromError(err error) int { switch { case err == nil: return zeeoneCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return zeeoneCodeInsufficientBalance case xerr.IsCode(err, xerr.SessionExpired): return zeeoneCodeSessionInvalid case xerr.IsCode(err, xerr.Unauthorized): return zeeoneCodeSessionNotFound default: return zeeoneCodeInvalidArgument } } func zeeoneMessageFromError(err error) string { switch zeeoneCodeFromError(err) { case zeeoneCodeInsufficientBalance: return "coin not enough" case zeeoneCodeSessionNotFound: return "sessionId not found" case zeeoneCodeSessionInvalid: return "sessionId invalid" default: if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { return xerr.MessageOf(err) } return "invalid argument" } }