2026-07-01 12:04:58 +08:00

508 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 baishunGameModeValue `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 = baishunGameModeValue(strings.TrimSpace(string(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 string(c.GameMode)
}
return "3"
}
type baishunGameModeValue string
func (v *baishunGameModeValue) UnmarshalJSON(raw []byte) error {
// 后台适配器配置是自由 JSON历史上既可能保存 "2",也可能保存 2。
// 百顺启动 query 最终只需要文本值;在配置入口归一,避免数字类型被 Go string 字段吞掉后误走默认 gameMode=3。
decoder := json.NewDecoder(strings.NewReader(strings.TrimSpace(string(raw))))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return err
}
switch typed := value.(type) {
case nil:
*v = ""
case string:
*v = baishunGameModeValue(strings.TrimSpace(typed))
case json.Number:
*v = baishunGameModeValue(strings.TrimSpace(typed.String()))
default:
// 复杂类型不是百顺协议值;按未配置处理,保留默认值兜底,同时不影响同一 JSON 中其它配置解析。
*v = ""
}
return nil
}
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)
}