package game import ( "context" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "math" "strconv" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) const ( // ZGame 文档的业务码会被游戏服务端直接消费;HTTP 200 只表示网关成功接收并处理回调。 zgameCodeOK = 0 zgameCodeInvalidSession = 201 zgameCodeInvalidSignature = 400 zgameCodeUnauthorized = 401 zgameCodeInvalidCode = 403 zgameCodeInsufficientBalance = 501 zgameCodeInternalError = 500 zgameDefaultTokenTTL = 24 * time.Hour ) type zgameAdapterConfig struct { // uid_mode 必须和启动链接、login open_id、后续签名 open_id 保持一致,避免同一 token 被不同用户标识复用。 UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` // game_urls 支持 ZGame 每款游戏独立 H5 地址;launch_url_template 用于批量上新时按 provider_game_id 生成地址。 GameURLs map[string]string `json:"game_urls"` LaunchURLTemplate string `json:"launch_url_template"` // callback_base 可按环境覆盖为完整公网 HTTPS 地址,避免 H5 在第三方域名下把相对路径解析到厂商域名。 CallbackBase string `json:"callback_base"` CallbackBaseAlias string `json:"callbackBase"` // merchant_payload 是 ZGame consume 回调的透传字段来源;只参与厂商回传,不进入内部业务判断。 MerchantPayload string `json:"merchant_payload"` // level/gender 是 ZGame 用户资料响应里的展示字段;真实等级接入前保持显式默认值,避免从其它体系误推。 Level int32 `json:"level"` Gender int32 `json:"gender"` } func zgameConfigFromPlatform(value any) zgameAdapterConfig { var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config zgameAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) if config.UIDMode == "" { // laluparty 游戏侧展示和签名都用短号作为 open_id;历史配置未写 uid_mode 时按文档默认走 display_user_id。 config.UIDMode = "display_user_id" } config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate) config.CallbackBase = strings.TrimSpace(firstNonEmpty(config.CallbackBase, config.CallbackBaseAlias)) config.MerchantPayload = strings.TrimSpace(config.MerchantPayload) config.GameURLs = normalizeStringMap(config.GameURLs) return config } func (c zgameAdapterConfig) TokenTTL() time.Duration { if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return zgameDefaultTokenTTL } func (c zgameAdapterConfig) CallbackBaseValue() string { if c.CallbackBase != "" { return c.CallbackBase } return "/api/v1/zgame/callback" } func zgameLaunchBaseURL(game gamedomain.LaunchableGame, config zgameAdapterConfig) 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) handleZGameOperation(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 zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", false, "") } config := zgameConfigFromPlatform(platform) switch zgameOperation(operation) { case "login": return s.handleZGameLogin(ctx, app, config, req) case "session_renew": return s.handleZGameSessionRenew(ctx, app, config, platform.CallbackSecretCiphertext, req) case "user_info_batch": return s.handleZGameUserInfoBatch(ctx, app, config, platform.CallbackSecretCiphertext, req) case "consume": return s.handleZGameConsume(ctx, app, config, platform.CallbackSecretCiphertext, req, requestHash) default: return zgameAdapterError(zgameCodeOK, "success", false, "") } } func zgameOperation(operation string) string { normalized := strings.Trim(strings.ToLower(strings.TrimSpace(operation)), "/") normalized = strings.TrimPrefix(normalized, "api/") normalized = strings.TrimPrefix(normalized, "server/") normalized = strings.TrimPrefix(normalized, "client/") return strings.Trim(normalized, "/") } type zgameLoginBody struct { OpenID string `json:"open_id"` OpenIDV2 string `json:"openId"` JSCode string `json:"js_code"` JSCodeV2 string `json:"jsCode"` JSCodeV3 string `json:"code"` CallbackURL string `json:"callback_url"` } func (body zgameLoginBody) openID() string { return firstNonEmpty(body.OpenID, body.OpenIDV2) } func (body zgameLoginBody) jsCode() string { return firstNonEmpty(body.JSCode, body.JSCodeV2, body.JSCodeV3) } func (s *Service) handleZGameLogin(ctx context.Context, app string, config zgameAdapterConfig, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { var body zgameLoginBody if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil { return zgameAdapterError(zgameCodeInvalidCode, "Invalid or Expired Code", false, "") } openID := body.openID() jsCode := body.jsCode() session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, jsCode, "") if err != nil || !zgameSessionMatches(session, config, openID, "") { return zgameAdapterError(zgameCodeInvalidCode, "Invalid or Expired Code", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return zgameAdapterError(zgameCodeFromError(err), zgameMessageFromError(err), true, "") } raw, contentType := zgameJSON(zgameCodeOK, "success", zgameLoginData(session, config, openID, jsCode, snapshot, s.now())) return raw, contentType, strconv.Itoa(zgameCodeOK), true, "", nil } type zgameSignedSessionBody struct { OpenID string `json:"open_id"` OpenIDV2 string `json:"openId"` Session string `json:"session"` Signature string `json:"signature"` RawGameID any `json:"game_id"` RawGameIDV2 any `json:"gameId"` RawTimestamp any `json:"timestamp"` } func (body zgameSignedSessionBody) openID() string { return firstNonEmpty(body.OpenID, body.OpenIDV2) } func (body zgameSignedSessionBody) gameIDText() string { return zgameAnyString(firstNonEmpty(zgameAnyString(body.RawGameID), zgameAnyString(body.RawGameIDV2))) } func (s *Service) handleZGameSessionRenew(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { body, signed, session, err := s.zgameSignedSession(ctx, app, config, secret, req) if !signed { return zgameAdapterError(zgameCodeInvalidSignature, "Invalid Signature", false, "") } if err != nil || !zgameSessionMatches(session, config, body.openID(), body.gameIDText()) { return zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", true, "") } raw, contentType := zgameJSON(zgameCodeOK, "success", map[string]any{ "session": body.Session, "session_expired": zgameSessionTTLMS(session, s.now()), }) return raw, contentType, strconv.Itoa(zgameCodeOK), true, "", nil } type zgameUserInfoBatchBody struct { OpenIDList []string `json:"open_id_list"` OpenIDListV2 []string `json:"openIdList"` OpenID string `json:"open_id"` OpenIDV2 string `json:"openId"` Session string `json:"session"` Signature string `json:"signature"` } func (body zgameUserInfoBatchBody) openID() string { return firstNonEmpty(body.OpenID, body.OpenIDV2) } func (body zgameUserInfoBatchBody) openIDList() []string { if len(body.OpenIDList) > 0 { return body.OpenIDList } return body.OpenIDListV2 } func (s *Service) handleZGameUserInfoBatch(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { var body zgameUserInfoBatchBody if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil { return zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", false, "") } if !zgameSignatureValid(body.openID(), body.Session, body.Signature, secret) { return zgameAdapterError(zgameCodeInvalidSignature, "Invalid Signature", false, "") } session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, body.Session, "") if err != nil || !zgameSessionMatches(session, config, body.openID(), "") { return zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", true, "") } userInfos := make(map[string]any, len(body.openIDList())) for _, openID := range body.openIDList() { openID = strings.TrimSpace(openID) if openID == "" { continue } data, ok := s.zgameUserInfoForOpenID(ctx, app, config, req, session, openID) if ok { userInfos[openID] = data } } raw, contentType := zgameJSON(zgameCodeOK, "success", map[string]any{"user_infos": userInfos}) return raw, contentType, strconv.Itoa(zgameCodeOK), true, "", nil } type zgameConsumeBody struct { OpenID string `json:"open_id"` OpenIDV2 string `json:"openId"` Session string `json:"session"` Amount json.Number `json:"amount"` OrderID string `json:"order_id"` OrderIDV2 string `json:"orderId"` GameID string `json:"game_id"` GameIDV2 string `json:"gameId"` Timestamp int64 `json:"timestamp"` RoundID string `json:"round_id"` RoundIDV2 string `json:"roundId"` Type int32 `json:"type"` MerchantPayload string `json:"merchant_payload"` MerchantPayloadV2 string `json:"merchantPayload"` Signature string `json:"signature"` } func (body zgameConsumeBody) openID() string { return firstNonEmpty(body.OpenID, body.OpenIDV2) } func (body zgameConsumeBody) orderID() string { return firstNonEmpty(body.OrderID, body.OrderIDV2) } func (body zgameConsumeBody) gameID() string { return firstNonEmpty(body.GameID, body.GameIDV2) } func (body zgameConsumeBody) roundID() string { return firstNonEmpty(body.RoundID, body.RoundIDV2) } func (body zgameConsumeBody) merchantPayload() string { return firstNonEmpty(body.MerchantPayload, body.MerchantPayloadV2) } func (s *Service) handleZGameConsume(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) { var body zgameConsumeBody if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil { return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", false, "") } if !zgameSignatureValid(body.openID(), body.Session, body.Signature, secret) { return zgameAdapterError(zgameCodeInvalidSignature, "Invalid Signature", false, body.orderID()) } session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, body.Session, body.gameID()) if err != nil || !zgameSessionMatches(session, config, body.openID(), body.gameID()) { return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", true, body.orderID()) } amount, amountOK := zgameAmount(body.Amount) opType, coinAmount := zgameOpType(body.Type, amount) if body.orderID() == "" || body.gameID() == "" || !amountOK || coinAmount < 0 || opType == "" { return zgameAdapterError(zgameCodeUnauthorized, "Unauthorized", true, body.orderID()) } if session.GameID == "" { session.GameID = body.gameID() } if session.ProviderGameID == "" { session.ProviderGameID = body.gameID() } providerOrderID := zgameProviderOrderID(body.orderID(), opType) result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.roundID(), opType, coinAmount, "", requestHash) if err != nil { code := zgameCodeFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) raw, contentType := zgameBalanceJSON(code, zgameMessageFromError(err), balance) return raw, contentType, strconv.Itoa(code), true, body.orderID(), nil } raw, contentType := zgameBalanceJSON(zgameCodeOK, "success", result.BalanceAfter) return raw, contentType, strconv.Itoa(zgameCodeOK), true, body.orderID(), nil } func (s *Service) zgameSignedSession(ctx context.Context, app string, config zgameAdapterConfig, secret string, req *gamev1.CallbackRequest) (zgameSignedSessionBody, bool, gamedomain.LaunchSession, error) { var body zgameSignedSessionBody if err := decodeYomiJSON(req.GetRawBody(), &body); err != nil { return zgameSignedSessionBody{}, false, gamedomain.LaunchSession{}, xerr.New(xerr.InvalidArgument, "invalid payload") } if !zgameSignatureValid(body.openID(), body.Session, body.Signature, secret) { return body, false, gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "signature invalid") } session, err := s.validZGameSession(ctx, app, gamedomain.PlatformCodeZGame, body.Session, body.gameIDText()) if err != nil { return body, true, gamedomain.LaunchSession{}, err } if !zgameSessionMatches(session, config, body.openID(), body.gameIDText()) { return body, true, gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session mismatch") } return body, true, session, nil } func (s *Service) validZGameSession(ctx context.Context, app string, platformCode string, token string, providerGameID string) (gamedomain.LaunchSession, error) { token = strings.TrimSpace(token) if token == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session invalid") } // 新版启动链路使用服务端短期 js_code;保留 JWT 兼容旧测试链接,避免已打开的 H5 立即失效。 if strings.Count(token, ".") == 2 { if session, err := s.appSessionFromAccessToken(app, token); err == nil { return session, nil } } if session, err := s.validScopedYomiSession(ctx, app, platformCode, token, providerGameID); err == nil { return session, nil } if strings.Count(token, ".") == 2 { return s.appSessionFromAccessToken(app, token) } return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session invalid") } func zgameSessionMatches(session gamedomain.LaunchSession, config zgameAdapterConfig, openID string, gameID string) bool { if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) { return false } openID = strings.TrimSpace(openID) return openID != "" && (openID == externalUserID(session, config.UIDMode) || openID == strings.TrimSpace(session.DisplayUserID) || openID == strconv.FormatInt(session.UserID, 10)) } func zgameSignatureValid(openID string, session string, signature string, secret string) bool { openID = strings.TrimSpace(openID) session = strings.TrimSpace(session) secret = strings.TrimSpace(secret) signature = strings.TrimSpace(signature) if openID == "" || session == "" || secret == "" || signature == "" { return false } sum := sha256.Sum256([]byte(openID + session + secret)) expected := hex.EncodeToString(sum[:]) return subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) == 1 } func zgameLoginData(session gamedomain.LaunchSession, config zgameAdapterConfig, requestedOpenID string, token string, snapshot yomiUserSnapshot, now time.Time) map[string]any { openID := strings.TrimSpace(requestedOpenID) if openID == "" { openID = externalUserID(session, config.UIDMode) } return map[string]any{ "session": token, "session_expired": zgameSessionTTLMS(session, now), "openId": openID, "userName": snapshot.Nickname, "nickname": snapshot.Nickname, "avatar": snapshot.Avatar, "balance": snapshot.Balance, "level": config.Level, "country": "", "gender": config.Gender, "sex": config.Gender, } } func (s *Service) zgameUserInfoForOpenID(ctx context.Context, app string, config zgameAdapterConfig, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, openID string) (map[string]any, bool) { userID := int64(0) if openID == externalUserID(session, config.UIDMode) || openID == strings.TrimSpace(session.DisplayUserID) { userID = session.UserID } else if !strings.EqualFold(config.UIDMode, "display_user_id") { // 批量查询没有 display_id -> user_id 解析依赖;默认 user_id 模式可直接按 open_id 查用户资料。 parsed, err := strconv.ParseInt(openID, 10, 64) if err == nil && parsed > 0 { userID = parsed } } if userID <= 0 { return nil, false } snapshot, err := s.yomiUserSnapshot(ctx, app, req, userID) if err != nil { return nil, false } return zgameUserInfoData(config, snapshot), true } func zgameUserInfoData(config zgameAdapterConfig, snapshot yomiUserSnapshot) map[string]any { return map[string]any{ "userName": snapshot.Nickname, "nickname": snapshot.Nickname, "gender": config.Gender, "level": config.Level, "avatar": snapshot.Avatar, "balance": snapshot.Balance, } } func zgameSessionTTLMS(session gamedomain.LaunchSession, now time.Time) int64 { if session.ExpiresAtMS <= 0 { return int64(zgameDefaultTokenTTL / time.Millisecond) } ttl := session.ExpiresAtMS - now.UnixMilli() if ttl < 0 { return 0 } return ttl } func zgameAmount(value json.Number) (int64, bool) { raw := strings.TrimSpace(value.String()) if raw == "" { return 0, false } amount, err := strconv.ParseInt(raw, 10, 64) if err != nil { return 0, false } return amount, true } func zgameOpType(op int32, amount int64) (string, int64) { if amount == -1<<63 { return "", -1 } coinAmount := amount if coinAmount < 0 { coinAmount = -coinAmount } if amount < 0 || op == 1 { return "credit", coinAmount } if op == 0 { return "debit", coinAmount } return "", coinAmount } func zgameProviderOrderID(orderID string, opType string) string { orderID = strings.TrimSpace(orderID) opType = strings.TrimSpace(opType) if orderID == "" || opType == "" { return orderID } return orderID + ":" + opType } func zgameAnyString(value any) string { switch typed := value.(type) { case nil: return "" case string: return strings.TrimSpace(typed) case json.Number: return strings.TrimSpace(typed.String()) case float64: if typed == math.Trunc(typed) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) case float32: return strconv.FormatFloat(float64(typed), 'f', -1, 32) case int: return strconv.Itoa(typed) case int64: return strconv.FormatInt(typed, 10) case int32: return strconv.FormatInt(int64(typed), 10) case uint64: return strconv.FormatUint(typed, 10) default: return strings.TrimSpace(fmt.Sprint(value)) } } func zgameAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { raw, contentType := zgameJSON(code, message, nil) return raw, contentType, strconv.Itoa(code), signatureValid, strings.TrimSpace(providerRequestID), nil } func zgameJSON(code int, message string, data any) ([]byte, string) { if strings.TrimSpace(message) == "" && code == zgameCodeOK { message = "success" } return jsonResponse(map[string]any{ "code": code, "errmsg": strings.TrimSpace(message), "data": data, }) } func zgameBalanceJSON(code int, message string, balance int64) ([]byte, string) { if strings.TrimSpace(message) == "" && code == zgameCodeOK { message = "success" } return jsonResponse(map[string]any{ "code": code, "errmsg": strings.TrimSpace(message), "balance": balance, "data": map[string]any{ "balance": balance, }, }) } func zgameCodeFromError(err error) int { switch { case err == nil: return zgameCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return zgameCodeInsufficientBalance case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized): return zgameCodeUnauthorized default: return zgameCodeInternalError } } func zgameMessageFromError(err error) string { switch zgameCodeFromError(err) { case zgameCodeInsufficientBalance: return "Insufficient Funds" case zgameCodeUnauthorized: return "Unauthorized" case zgameCodeOK: return "success" default: return "Internal Error" } }