package game import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" "strconv" "strings" "time" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) const ( // VIVAGAMES 文档定义 code=0 成功;游戏侧按 JSON code 判断业务结果,HTTP 200 只表示回调已被接收。 vivaGamesCodeOK = 0 vivaGamesCodeAppNotFound = 1001 vivaGamesCodeInvalidArgument = 1002 vivaGamesCodeSignatureInvalid = 1003 vivaGamesCodeCodeInvalid = 1004 vivaGamesCodeTokenInvalid = 1005 vivaGamesCodeInternalError = 1006 vivaGamesCodeGameMaintenance = 1007 vivaGamesCodeInsufficientBalance = 1008 vivaGamesCodeUserDisabled = 1009 vivaGamesCodeRequestExpired = 1010 vivaGamesDefaultTokenTTL = 24 * time.Hour vivaGamesSignatureValidWindow = 5 * time.Minute ) type vivaGamesAdapterConfig struct { // app_id 来自 VIVAGAMES 后台;callbackSecret 存放 PDF 签名算法使用的 AppKey。 AppID int64 `json:"app_id"` AppIDAlias int64 `json:"appId"` AppSecret string `json:"app_secret"` UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` // token_ttl_seconds 控制启动 session 至少保留多久,避免 H5 游戏中途回调时 token 已过期。 TokenTTLSeconds int64 `json:"token_ttl_seconds"` // 以下字段直接对应 NativeBridge.getConfig,后台热改后下一次启动生效。 Metadata string `json:"metadata"` CoinType int64 `json:"coin_type"` AmountRate int64 `json:"amount_rate"` BGMSwitch *int64 `json:"bgm_switch"` GSP int64 `json:"gsp"` CurrencyIcon string `json:"currency_icon"` // VIVAGAMES 文档没有游戏列表 API,game_urls 用后台配置保存厂商提供的逐游戏 H5 URL。 GameURLs map[string]string `json:"game_urls"` LaunchURLTemplate string `json:"launch_url_template"` } func vivaGamesConfigFromPlatform(value any) vivaGamesAdapterConfig { var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config vivaGamesAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) if config.AppID == 0 { config.AppID = config.AppIDAlias } config.AppSecret = strings.TrimSpace(config.AppSecret) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.Metadata = strings.TrimSpace(config.Metadata) config.CurrencyIcon = strings.TrimSpace(config.CurrencyIcon) config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate) config.GameURLs = normalizeStringMap(config.GameURLs) return config } func (c vivaGamesAdapterConfig) TokenTTL() time.Duration { if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return vivaGamesDefaultTokenTTL } func (c vivaGamesAdapterConfig) LanguageValue() string { if c.DefaultLang != "" { return c.DefaultLang } return "en-US" } func (c vivaGamesAdapterConfig) AmountRateValue() int64 { if c.AmountRate > 0 { return c.AmountRate } return 1 } func (c vivaGamesAdapterConfig) BGMSwitchValue() int64 { if c.BGMSwitch != nil { return *c.BGMSwitch } return 1 } func (c vivaGamesAdapterConfig) GSPValue() int64 { if c.GSP > 0 { return c.GSP } return 101 } func (c vivaGamesAdapterConfig) MetadataValue(session gamedomain.LaunchSession) string { if c.Metadata != "" { return c.Metadata } if strings.TrimSpace(session.RoomID) == "" { return "" } raw, err := json.Marshal(map[string]string{"room_id": strings.TrimSpace(session.RoomID)}) if err != nil { return "" } return string(raw) } func vivaGamesLaunchBaseURL(game gamedomain.LaunchableGame, config vivaGamesAdapterConfig) 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 vivaGamesGameConfigQuery(currencyIcon string) string { raw, err := json.Marshal(map[string]string{"currencyIcon": strings.TrimSpace(currencyIcon)}) if err != nil { return "" } return string(raw) } func (s *Service) handleVivaGamesOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { raw, contentType := vivaGamesJSON(vivaGamesCodeSignatureInvalid, "ip restricted", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeSignatureInvalid), false, "", nil } config := vivaGamesConfigFromPlatform(platform) body, signed, decoded, expired, providerRequestID := s.decodeVivaGamesBody(req, platform.CallbackSecretCiphertext) if !decoded { raw, contentType := vivaGamesJSON(vivaGamesCodeInvalidArgument, "invalid argument", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeInvalidArgument), false, providerRequestID, nil } if expired { raw, contentType := vivaGamesJSON(vivaGamesCodeRequestExpired, "request expired", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeRequestExpired), false, providerRequestID, nil } if !signed { raw, contentType := vivaGamesJSON(vivaGamesCodeSignatureInvalid, "sign error", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeSignatureInvalid), false, providerRequestID, nil } appID := body.AppIDInt64() if appID <= 0 { raw, contentType := vivaGamesJSON(vivaGamesCodeInvalidArgument, "invalid app_id", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeInvalidArgument), signed, providerRequestID, nil } if config.AppID > 0 && appID != config.AppID { raw, contentType := vivaGamesJSON(vivaGamesCodeAppNotFound, "appId not found", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeAppNotFound), signed, providerRequestID, nil } switch operation { case "get-token", "get_token", "gettoken", "api/get-token", "api/get_token", "v1/api/get-token": return s.handleVivaGamesGetToken(ctx, app, config, req, body) case "get-user-info", "get_user_info", "userinfo", "user_info", "api/get-user-info", "api/get_user_info", "v1/api/get-user-info": return s.handleVivaGamesUserInfo(ctx, app, config, req, body) case "update-token", "update_token", "updatetoken", "api/update-token", "api/update_token", "v1/api/update-token": return s.handleVivaGamesUpdateToken(ctx, app, config, body) case "change-balance", "change_balance", "changebalance", "api/change-balance", "api/change_balance", "v1/api/change-balance": return s.handleVivaGamesChangeBalance(ctx, app, config, req, body, requestHash) default: raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{}) return raw, contentType, strconv.Itoa(vivaGamesCodeOK), signed, providerRequestID, nil } } type vivaGamesBody struct { AppID json.Number `json:"app_id"` UserID string `json:"user_id"` Code string `json:"code"` Token string `json:"token"` ClientIP string `json:"client_ip"` GameID json.Number `json:"game_id"` OrderID string `json:"order_id"` GameRoundID string `json:"game_round_id"` TotalBet int64 `json:"total_bet"` ChangeAmount int64 `json:"change_amount"` ChangeReason string `json:"change_reason"` ChangeTimeAt int64 `json:"change_time_at"` Metadata string `json:"metadata"` ExtendType string `json:"extend_type"` Extend string `json:"extend"` IgnoreExpire bool `json:"ignore_expire"` Timestamp int64 `json:"timestamp"` Signature string `json:"sign"` } func (s *Service) decodeVivaGamesBody(req *gamev1.CallbackRequest, secret string) (vivaGamesBody, bool, bool, bool, string) { var body vivaGamesBody decoder := json.NewDecoder(strings.NewReader(string(req.GetRawBody()))) decoder.UseNumber() if err := decoder.Decode(&body); err != nil { return vivaGamesBody{}, false, false, false, "" } body.normalize() providerRequestID := strings.TrimSpace(body.OrderID) params, err := vivaGamesParamsForSign(req.GetRawBody()) if err != nil { return vivaGamesBody{}, false, false, false, providerRequestID } signed, expired := s.vivaGamesSignatureValid(params, body.Signature, body.Timestamp, secret) return body, signed, true, expired, providerRequestID } func vivaGamesParamsForSign(raw []byte) (map[string]any, error) { var params map[string]any decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.UseNumber() if err := decoder.Decode(¶ms); err != nil { return nil, err } delete(params, "sign") return params, nil } func (s *Service) vivaGamesSignatureValid(params map[string]any, signature string, timestamp int64, secret string) (bool, bool) { secret = strings.TrimSpace(secret) if secret == "" || strings.TrimSpace(signature) == "" || timestamp <= 0 { return false, false } requestAt := time.Unix(timestamp, 0) now := s.now() if requestAt.After(now.Add(vivaGamesSignatureValidWindow)) || now.Sub(requestAt) > vivaGamesSignatureValidWindow { return false, true } expected := vivaGamesSign(params, secret) return strings.EqualFold(strings.TrimSpace(signature), expected), false } func vivaGamesSign(params map[string]any, secret string) string { keys := make([]string, 0, len(params)) for key, value := range params { if value == nil || strings.TrimSpace(key) == "" || strings.EqualFold(key, "sign") { continue } keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)+1) for _, key := range keys { parts = append(parts, fmt.Sprintf("%s=%s", key, vivaGamesSignValue(params[key]))) } parts = append(parts, "key="+strings.TrimSpace(secret)) sum := sha256.Sum256([]byte(strings.Join(parts, "&"))) return hex.EncodeToString(sum[:]) } func vivaGamesSignValue(value any) string { switch typed := value.(type) { case json.Number: return typed.String() case string: return typed case bool: if typed { return "true" } return "false" default: return strings.TrimSpace(fmt.Sprint(typed)) } } func (body *vivaGamesBody) normalize() { body.UserID = strings.TrimSpace(body.UserID) body.Code = strings.TrimSpace(body.Code) body.Token = strings.TrimSpace(body.Token) body.ClientIP = strings.TrimSpace(body.ClientIP) body.OrderID = strings.TrimSpace(body.OrderID) body.GameRoundID = strings.TrimSpace(body.GameRoundID) body.ChangeReason = strings.ToLower(strings.TrimSpace(body.ChangeReason)) body.Metadata = strings.TrimSpace(body.Metadata) body.ExtendType = strings.TrimSpace(body.ExtendType) body.Extend = strings.TrimSpace(body.Extend) body.Signature = strings.TrimSpace(body.Signature) } func (body vivaGamesBody) AppIDInt64() int64 { value, _ := strconv.ParseInt(strings.TrimSpace(body.AppID.String()), 10, 64) return value } func (body vivaGamesBody) GameIDInt64() int64 { value, _ := strconv.ParseInt(strings.TrimSpace(body.GameID.String()), 10, 64) return value } func (s *Service) handleVivaGamesGetToken(ctx context.Context, app string, config vivaGamesAdapterConfig, req *gamev1.CallbackRequest, body vivaGamesBody) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.Code == "" { return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, "") } session, err := s.validVivaGamesSession(ctx, app, body.Code, false) if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, 0) { return vivaGamesAdapterError(vivaGamesCodeCodeInvalid, "code error", true, "") } raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{ "token": body.Code, "expire_at": session.ExpiresAtMS, }) return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, "", nil } func (s *Service) handleVivaGamesUserInfo(ctx context.Context, app string, config vivaGamesAdapterConfig, req *gamev1.CallbackRequest, body vivaGamesBody) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.Token == "" { return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, "") } session, err := s.validVivaGamesSession(ctx, app, body.Token, false) if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, body.GameIDInt64()) { return vivaGamesAdapterError(vivaGamesCodeTokenInvalid, "token error", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return vivaGamesAdapterError(vivaGamesCodeFromError(err), vivaGamesMessageFromError(err), true, "") } raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{ "user_id": externalUserID(session, config.UIDMode), "nickname": snapshot.Nickname, "avatar_url": snapshot.Avatar, "balance": snapshot.Balance, "status": 0, }) return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, "", nil } func (s *Service) handleVivaGamesUpdateToken(ctx context.Context, app string, config vivaGamesAdapterConfig, body vivaGamesBody) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.Token == "" { return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, "") } session, err := s.validVivaGamesSession(ctx, app, body.Token, false) if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, 0) { return vivaGamesAdapterError(vivaGamesCodeTokenInvalid, "token error", true, "") } raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{ "token": body.Token, "expire_at": session.ExpiresAtMS, }) return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, "", nil } func (s *Service) handleVivaGamesChangeBalance(ctx context.Context, app string, config vivaGamesAdapterConfig, req *gamev1.CallbackRequest, body vivaGamesBody, requestHash string) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.Token == "" || body.OrderID == "" || body.GameIDInt64() <= 0 || body.ChangeReason == "" { return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, body.OrderID) } session, err := s.validVivaGamesSession(ctx, app, body.Token, body.IgnoreExpire) if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, body.GameIDInt64()) { return vivaGamesAdapterError(vivaGamesCodeTokenInvalid, "token error", true, body.OrderID) } if session.ProviderGameID == "" { session.ProviderGameID = strconv.FormatInt(body.GameIDInt64(), 10) } if session.GameID == "" { session.GameID = session.ProviderGameID } opType, amount := vivaGamesOpType(body.ChangeAmount) result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, body.GameRoundID, opType, amount, "", requestHash) if err != nil { code := vivaGamesCodeFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) raw, contentType := vivaGamesJSON(code, vivaGamesMessageFromError(err), map[string]any{"balance": balance}) return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil } raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{"balance": result.BalanceAfter}) return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, body.OrderID, nil } func (s *Service) validVivaGamesSession(ctx context.Context, app string, token string, ignoreExpire bool) (gamedomain.LaunchSession, error) { token = strings.TrimSpace(token) if token == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } // VIVAGAMES token 直接复用 App access token;先解析 JWT 可以避免同一登录 token 多游戏启动时命中过期旧 session。 if strings.Count(token, ".") == 2 && !ignoreExpire { if session, err := s.appSessionFromAccessToken(app, token); err == nil { return session, nil } } if session, err := s.repository.GetLaunchSessionByToken(ctx, app, token); err == nil { if session.Status != gamedomain.SessionActive { return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired") } if !ignoreExpire && session.ExpiresAtMS <= s.now().UnixMilli() { return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired") } return session, nil } if strings.Count(token, ".") == 2 { return s.appSessionFromAccessToken(app, token) } return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } func vivaGamesSessionMatches(session gamedomain.LaunchSession, config vivaGamesAdapterConfig, 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 == externalUserID(session, config.UIDMode) || userID == strings.TrimSpace(session.DisplayUserID) || userID == strconv.FormatInt(session.UserID, 10) } func vivaGamesOpType(changeAmount int64) (string, int64) { if changeAmount < 0 { return "debit", -changeAmount } return "credit", changeAmount } func vivaGamesAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { raw, contentType := vivaGamesJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func vivaGamesJSON(code int, message string, data any) ([]byte, string) { if data == nil { data = map[string]any{} } return jsonResponse(map[string]any{ "code": code, "msg": strings.TrimSpace(message), "data": data, }) } func vivaGamesCodeFromError(err error) int { switch { case err == nil: return vivaGamesCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return vivaGamesCodeInsufficientBalance case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized): return vivaGamesCodeTokenInvalid case xerr.IsCode(err, xerr.InvalidArgument): return vivaGamesCodeInvalidArgument case xerr.IsCode(err, xerr.Conflict): return vivaGamesCodeGameMaintenance case xerr.IsCode(err, xerr.NotFound): return vivaGamesCodeTokenInvalid default: return vivaGamesCodeInternalError } } func vivaGamesMessageFromError(err error) string { switch vivaGamesCodeFromError(err) { case vivaGamesCodeInsufficientBalance: return "balance not enough" case vivaGamesCodeTokenInvalid: return "token error" case vivaGamesCodeInvalidArgument: return "invalid argument" case vivaGamesCodeGameMaintenance: return "game maintenance" default: if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { return xerr.MessageOf(err) } return "internal error" } }