2026-06-06 13:50:46 +08:00

495 lines
19 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/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 文档没有游戏列表 APIgame_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(&params); 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"
}
}