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 ( // BAISHUN 文档定义 code=0 成功;余额不足固定 1008,签名错误固定 1003。 baishunCodeOK = 0 baishunCodeInvalidData = 1001 baishunCodeReadDataFailed = 1002 baishunCodeSignatureInvalid = 1003 baishunCodeInsufficientBalance = 1008 baishunCodeGameMaintenance = 1019 baishunCodeAPIServerError = 1021 baishunCodeIPRestricted = 1023 baishunDefaultTokenTTL = 24 * time.Hour baishunSignatureValidWindow = 15 * time.Second baishunDefaultSuccessMessage = "succeed" ) type baishunAdapterConfig struct { // app_id/app_channel/app_key 均来自百顺后台;app_key 存在平台 callbackSecret 中。 AppID int64 `json:"app_id"` AppIDAlias int64 `json:"appId"` AppChannel string `json:"app_channel"` AppChannelAlias string `json:"appChannel"` UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` // 以下字段是 NativeBridge.getConfig 需要返回给 H5 的配置,同时也会拼在启动 URL query 中给壳层复用。 GameMode string `json:"game_mode"` SceneMode int64 `json:"scene_mode"` CurrencyIcon string `json:"currency_icon"` GSP int64 `json:"gsp"` // 可选用户管控字段;配置后会随 get_sstoken/get_user_info 一起返回给百顺。 UserType int `json:"user_type"` ReleaseCond int `json:"release_cond"` IsOldUser *bool `json:"is_old_user"` Extend string `json:"extend"` // game_urls 支持同一个百顺商户下不同 game_id 使用不同 download_url。 GameURLs map[string]string `json:"game_urls"` LaunchURLTemplate string `json:"launch_url_template"` } func baishunConfigFromPlatform(value any) baishunAdapterConfig { var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config baishunAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) if config.AppID == 0 { config.AppID = config.AppIDAlias } if strings.TrimSpace(config.AppChannel) == "" { config.AppChannel = config.AppChannelAlias } config.AppChannel = strings.TrimSpace(config.AppChannel) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.GameMode = strings.TrimSpace(config.GameMode) config.CurrencyIcon = strings.TrimSpace(config.CurrencyIcon) config.Extend = strings.TrimSpace(config.Extend) config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate) config.GameURLs = normalizeStringMap(config.GameURLs) return config } func (c baishunAdapterConfig) TokenTTL() time.Duration { if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return baishunDefaultTokenTTL } func (c baishunAdapterConfig) LanguageValue() string { if c.DefaultLang != "" { return c.DefaultLang } return "2" } func (c baishunAdapterConfig) GameModeValue() string { if c.GameMode != "" { return c.GameMode } return "3" } func baishunLaunchBaseURL(game gamedomain.LaunchableGame, config baishunAdapterConfig) 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) handleBaishunOperation(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 := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeIPRestricted, "ip restricted", map[string]any{}) return raw, contentType, strconv.Itoa(baishunCodeIPRestricted), false, "", nil } config := baishunConfigFromPlatform(platform) body, signed, decoded, providerRequestID := s.decodeBaishunBody(req, platform.CallbackSecretCiphertext) if !signed { raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeSignatureInvalid, "signature invalid", map[string]any{}) return raw, contentType, strconv.Itoa(baishunCodeSignatureInvalid), false, providerRequestID, nil } if !decoded { raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "invalid data", map[string]any{}) return raw, contentType, strconv.Itoa(baishunCodeInvalidData), signed, providerRequestID, nil } if err := validateBaishunAppID(config, body.AppIDInt64()); err != nil { raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "invalid app_id", map[string]any{}) return raw, contentType, strconv.Itoa(baishunCodeInvalidData), signed, providerRequestID, nil } switch operation { case "get_sstoken", "getsstoken", "sstoken", "api/get_sstoken": return s.handleBaishunGetSSToken(ctx, app, config, req, body) case "get_user_info", "userinfo", "user_info", "api/get_user_info": return s.handleBaishunUserInfo(ctx, app, config, req, body) case "update_sstoken", "updatesstoken", "refresh_sstoken", "api/update_sstoken": return s.handleBaishunUpdateSSToken(ctx, app, config, req, body) case "change_balance", "changebalance", "api/change_balance": return s.handleBaishunChangeBalance(ctx, app, config, req, body, requestHash) default: raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, map[string]any{}) return raw, contentType, strconv.Itoa(baishunCodeOK), signed, providerRequestID, nil } } type baishunBody struct { AppID json.Number `json:"app_id"` UserID string `json:"user_id"` Code string `json:"code"` SSToken string `json:"ss_token"` ClientIP string `json:"client_ip"` GameID json.Number `json:"game_id"` CurrencyDiff int64 `json:"currency_diff"` DiffMsg string `json:"diff_msg"` GameRoundID string `json:"game_round_id"` RoomID string `json:"room_id"` ChangeTimeAt int64 `json:"change_time_at"` OrderID string `json:"order_id"` Extend string `json:"extend"` MsgType string `json:"msg_type"` CurrencyType json.Number `json:"currency_type"` SignatureNonce string `json:"signature_nonce"` Timestamp int64 `json:"timestamp"` Signature string `json:"signature"` ProviderName string `json:"provider_name"` } func (s *Service) decodeBaishunBody(req *gamev1.CallbackRequest, secret string) (baishunBody, bool, bool, string) { var body baishunBody decoder := json.NewDecoder(strings.NewReader(string(req.GetRawBody()))) decoder.UseNumber() if err := decoder.Decode(&body); err != nil { return baishunBody{}, false, false, "" } body.normalize() providerRequestID := strings.TrimSpace(body.OrderID) if !s.baishunSignatureValid(body, secret) { return baishunBody{}, false, true, providerRequestID } return body, true, true, providerRequestID } func (s *Service) baishunSignatureValid(body baishunBody, secret string) bool { secret = strings.TrimSpace(secret) if secret == "" || body.SignatureNonce == "" || body.Timestamp <= 0 { return false } requestAt := time.Unix(body.Timestamp, 0) now := s.now() if requestAt.After(now.Add(baishunSignatureValidWindow)) || now.Sub(requestAt) > baishunSignatureValidWindow { return false } raw := fmt.Sprintf("%s%s%d", body.SignatureNonce, secret, body.Timestamp) sum := md5.Sum([]byte(raw)) if !strings.EqualFold(body.Signature, hex.EncodeToString(sum[:])) { return false } // 文档要求 signature_nonce 在 15 秒窗口内不可重复;这里做单进程防重放,多节点严格一致需接共享缓存。 if !s.rememberSignatureNonce(gamedomain.AdapterBaishunV1, body.SignatureNonce, requestAt, baishunSignatureValidWindow) { return false } return true } func (body *baishunBody) normalize() { body.UserID = strings.TrimSpace(body.UserID) body.Code = strings.TrimSpace(body.Code) body.SSToken = strings.TrimSpace(body.SSToken) body.ClientIP = strings.TrimSpace(body.ClientIP) body.DiffMsg = strings.ToLower(strings.TrimSpace(body.DiffMsg)) body.GameRoundID = strings.TrimSpace(body.GameRoundID) body.RoomID = strings.TrimSpace(body.RoomID) body.OrderID = strings.TrimSpace(body.OrderID) body.Extend = strings.TrimSpace(body.Extend) body.MsgType = strings.TrimSpace(body.MsgType) body.SignatureNonce = strings.TrimSpace(body.SignatureNonce) body.Signature = strings.TrimSpace(body.Signature) body.ProviderName = strings.TrimSpace(body.ProviderName) } func (body baishunBody) AppIDInt64() int64 { value, _ := strconv.ParseInt(strings.TrimSpace(body.AppID.String()), 10, 64) return value } func (body baishunBody) GameIDInt64() int64 { value, _ := strconv.ParseInt(strings.TrimSpace(body.GameID.String()), 10, 64) return value } func validateBaishunAppID(config baishunAdapterConfig, appID int64) error { if appID <= 0 { return xerr.New(xerr.InvalidArgument, "app_id required") } if config.AppID > 0 && appID != config.AppID { return xerr.New(xerr.InvalidArgument, "app_id mismatch") } return nil } func (s *Service) handleBaishunGetSSToken(ctx context.Context, app string, config baishunAdapterConfig, req *gamev1.CallbackRequest, body baishunBody) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.Code == "" { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "invalid data", true, "") } session, err := s.validBaishunSession(ctx, app, body.Code) if err != nil || !baishunSessionMatches(session, config, body.UserID, 0) { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "token invalid", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunUserSnapshotCodeFromError(err), baishunUserSnapshotMessageFromError(err), true, "") } response := map[string]any{ "code": baishunCodeOK, "message": baishunDefaultSuccessMessage, "unique_id": baishunUniqueID(req.GetMeta().GetRequestId()), "data": map[string]any{ "ss_token": body.Code, "expire_date": session.ExpiresAtMS, }, "user_info": baishunUserInfoData(session, config, snapshot), } raw, contentType := jsonResponse(response) return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil } func (s *Service) handleBaishunUserInfo(ctx context.Context, app string, config baishunAdapterConfig, req *gamev1.CallbackRequest, body baishunBody) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.SSToken == "" { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "invalid data", true, "") } session, err := s.validBaishunSession(ctx, app, body.SSToken) if err != nil || !baishunSessionMatches(session, config, body.UserID, body.GameIDInt64()) { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "token invalid", true, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunUserSnapshotCodeFromError(err), baishunUserSnapshotMessageFromError(err), true, "") } raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, baishunUserInfoData(session, config, snapshot)) return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil } func (s *Service) handleBaishunUpdateSSToken(ctx context.Context, app string, config baishunAdapterConfig, req *gamev1.CallbackRequest, body baishunBody) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.SSToken == "" { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "invalid data", true, "") } session, err := s.validBaishunSession(ctx, app, body.SSToken) if err != nil || !baishunSessionMatches(session, config, body.UserID, body.GameIDInt64()) { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "token invalid", true, "") } raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, map[string]any{ "ss_token": body.SSToken, "expire_date": session.ExpiresAtMS, }) return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil } func (s *Service) handleBaishunChangeBalance(ctx context.Context, app string, config baishunAdapterConfig, req *gamev1.CallbackRequest, body baishunBody, requestHash string) ([]byte, string, string, bool, string, error) { if body.UserID == "" || body.SSToken == "" || body.OrderID == "" || body.GameIDInt64() <= 0 || body.DiffMsg == "" { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "invalid data", true, body.OrderID) } session, err := s.validBaishunSession(ctx, app, body.SSToken) if err != nil || !baishunSessionMatches(session, config, body.UserID, body.GameIDInt64()) { return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeInvalidData, "token invalid", true, body.OrderID) } if session.ProviderGameID == "" { session.ProviderGameID = strconv.FormatInt(body.GameIDInt64(), 10) } if session.GameID == "" { session.GameID = session.ProviderGameID } opType, amount := baishunOpType(body.CurrencyDiff) result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, body.GameRoundID, opType, amount, body.RoomID, requestHash) if err != nil { code := baishunCodeFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), code, baishunMessageFromError(err), baishunChangeBalanceData(balance, body.Extend)) return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil } raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, baishunChangeBalanceData(result.BalanceAfter, body.Extend)) return raw, contentType, strconv.Itoa(baishunCodeOK), true, body.OrderID, nil } func (s *Service) validBaishunSession(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") } // 百顺的 ss_token 直接复用 App access token;同一个登录 token 可能打开多款百顺游戏。 // JWT 必须优先解析,否则会先命中旧启动会话的 hash,并被旧 provider_game_id 误判为跨游戏复用。 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 baishunSessionMatches(session gamedomain.LaunchSession, config baishunAdapterConfig, 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 baishunOpType(diff int64) (string, int64) { if diff < 0 { return "debit", -diff } return "credit", diff } func baishunUserInfoData(session gamedomain.LaunchSession, config baishunAdapterConfig, snapshot yomiUserSnapshot) map[string]any { data := map[string]any{ "user_id": externalUserID(session, config.UIDMode), "user_name": snapshot.Nickname, "user_avatar": snapshot.Avatar, "balance": snapshot.Balance, } if config.UserType > 0 { data["user_type"] = config.UserType } if config.ReleaseCond >= 0 && config.UserType > 0 { data["release_cond"] = config.ReleaseCond } if config.IsOldUser != nil { data["is_old_user"] = *config.IsOldUser } if config.Extend != "" { data["extend"] = config.Extend } return data } func baishunChangeBalanceData(balance int64, extend string) map[string]any { data := map[string]any{"currency_balance": balance} if strings.TrimSpace(extend) != "" { data["extend"] = strings.TrimSpace(extend) } return data } func baishunAdapterError(uniqueID string, code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { raw, contentType := baishunJSON(uniqueID, code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil } func baishunJSON(uniqueID string, code int, message string, data any) ([]byte, string) { if strings.TrimSpace(message) == "" && code == baishunCodeOK { message = baishunDefaultSuccessMessage } if data == nil { data = map[string]any{} } return jsonResponse(map[string]any{ "code": code, "message": strings.TrimSpace(message), "unique_id": baishunUniqueID(uniqueID), "data": data, }) } func baishunUniqueID(value string) string { value = strings.TrimSpace(value) if value != "" { return value } return strconv.FormatInt(time.Now().UnixNano(), 10) } func baishunCodeFromError(err error) int { switch { case err == nil: return baishunCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return baishunCodeInsufficientBalance case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized), xerr.IsCode(err, xerr.InvalidArgument): return baishunCodeInvalidData case xerr.IsCode(err, xerr.NotFound): return baishunCodeReadDataFailed case xerr.IsCode(err, xerr.Conflict): return baishunCodeGameMaintenance default: return baishunCodeAPIServerError } } func baishunMessageFromError(err error) string { switch baishunCodeFromError(err) { case baishunCodeInsufficientBalance: return "coin not enough" case baishunCodeInvalidData: return "invalid data" case baishunCodeReadDataFailed: return "read data failed" case baishunCodeGameMaintenance: return "game maintenance" default: if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { return xerr.MessageOf(err) } return "api server error" } } func baishunUserSnapshotCodeFromError(err error) int { if xerr.IsCode(err, xerr.NotFound) { return baishunCodeInvalidData } return baishunCodeFromError(err) } func baishunUserSnapshotMessageFromError(err error) string { if xerr.IsCode(err, xerr.NotFound) { return "token invalid" } return baishunMessageFromError(err) }