2026-06-09 11:04:31 +08:00

347 lines
14 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"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const (
// 热游文档只定义这几个业务码HTTP 200 仅表示回调已被接收,游戏侧以 errorCode 判定业务结果。
reyouCodeOK = 0
reyouCodeTokenInvalid = 1001
reyouCodeInsufficientBalance = 2001
reyouCodeDuplicatedOrder = 3001
reyouDefaultTokenTTL = 24 * time.Hour
)
type reyouAdapterConfig struct {
// uid_mode 必须同时约束启动 URL 和回调校验,避免同一 token 被不同用户标识复用。
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
// 热游文档没有提供游戏列表 API后台用 game_urls 保存厂商给的逐游戏 H5 地址。
GameURLs map[string]string `json:"game_urls"`
LaunchURLTemplate string `json:"launch_url_template"`
}
func reyouConfigFromPlatform(value any) reyouAdapterConfig {
// 启动链路拿 LaunchableGame回调链路拿 Platform两者共用一套 JSON 字段,避免配置漂移。
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
raw = typed.AdapterConfigJSON
case gamedomain.LaunchableGame:
raw = typed.AdapterConfigJSON
}
var config reyouAdapterConfig
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
config.GameURLs = normalizeStringMap(config.GameURLs)
return config
}
func (c reyouAdapterConfig) TokenTTL() time.Duration {
// 热游要求 token 在游戏中以及延迟结算后至少 3 分钟仍有效;默认 24 小时覆盖长局和重试场景。
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
return reyouDefaultTokenTTL
}
func (c reyouAdapterConfig) LanguageValue() string {
if c.DefaultLang != "" {
return c.DefaultLang
}
return "en"
}
func reyouLaunchBaseURL(game gamedomain.LaunchableGame, config reyouAdapterConfig) 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) handleReyouOperation(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 reyouAdapterError(reyouCodeTokenInvalid, "ip restricted", false, "")
}
switch operation {
case "getuserinfo", "get_user_info", "get-user-info", "userinfo", "user_info":
return s.handleReyouUserInfo(ctx, app, platform, req)
case "updatebalance", "update_balance", "update-balance", "change_balance":
return s.handleReyouUpdateBalance(ctx, app, platform, req, requestHash)
default:
raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(reyouCodeOK), false, "", nil
}
}
type reyouUserInfoBody struct {
GameID json.RawMessage `json:"gameId"`
UID json.RawMessage `json:"uid"`
Token json.RawMessage `json:"token"`
Sign string `json:"sign"`
}
func (body reyouUserInfoBody) gameIDText() string { return reyouJSONText(body.GameID) }
func (body reyouUserInfoBody) uidText() string { return reyouJSONText(body.UID) }
func (body reyouUserInfoBody) tokenText() string { return reyouJSONText(body.Token) }
type reyouUpdateBalanceBody struct {
OrderID json.RawMessage `json:"orderId"`
GameID json.RawMessage `json:"gameId"`
RoundID json.RawMessage `json:"roundId"`
UID json.RawMessage `json:"uid"`
Coin json.RawMessage `json:"coin"`
Type json.RawMessage `json:"type"`
Token json.RawMessage `json:"token"`
Sign string `json:"sign"`
}
func (body reyouUpdateBalanceBody) orderIDText() string { return reyouJSONText(body.OrderID) }
func (body reyouUpdateBalanceBody) gameIDText() string { return reyouJSONText(body.GameID) }
func (body reyouUpdateBalanceBody) roundIDText() string { return reyouJSONText(body.RoundID) }
func (body reyouUpdateBalanceBody) uidText() string { return reyouJSONText(body.UID) }
func (body reyouUpdateBalanceBody) coinText() string { return reyouJSONText(body.Coin) }
func (body reyouUpdateBalanceBody) typeText() string { return reyouJSONText(body.Type) }
func (body reyouUpdateBalanceBody) tokenText() string { return reyouJSONText(body.Token) }
func (s *Service) handleReyouUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
var body reyouUserInfoBody
if err := decodeReyouJSON(req.GetRawBody(), &body); err != nil {
return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", false, "")
}
gameID := body.gameIDText()
uid := body.uidText()
token := body.tokenText()
// 文档签名顺序固定为 gameId + uid + token + key不能排序也不能插入分隔符。
if !reyouSignValid(body.Sign, platform.CallbackSecretCiphertext, gameID, uid, token) {
return reyouAdapterError(reyouCodeTokenInvalid, "signature invalid", false, "")
}
config := reyouConfigFromPlatform(platform)
session, err := s.validReyouSession(ctx, app, token)
// token 通过后继续绑定 uid/gameId防止同一登录 token 被跨用户或跨游戏复用。
if err != nil || !reyouSessionMatches(session, config, uid, gameID) {
return reyouAdapterError(reyouCodeTokenInvalid, "token invalid", true, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return reyouAdapterError(reyouCodeFromError(err), reyouMessageFromError(err), true, "")
}
raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{
"uid": uid,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
"coin": snapshot.Balance,
"vipLevel": int64(0),
})
return raw, contentType, strconv.Itoa(reyouCodeOK), true, "", nil
}
func (s *Service) handleReyouUpdateBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) {
var body reyouUpdateBalanceBody
if err := decodeReyouJSON(req.GetRawBody(), &body); err != nil {
return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", false, "")
}
providerOrderID := body.orderIDText()
gameID := body.gameIDText()
roundID := body.roundIDText()
uid := body.uidText()
coinText := body.coinText()
typeText := body.typeText()
token := body.tokenText()
// 文档签名顺序固定为 orderId + gameId + roundId + uid + coin + type + token + key。
if !reyouSignValid(body.Sign, platform.CallbackSecretCiphertext, providerOrderID, gameID, roundID, uid, coinText, typeText, token) {
return reyouAdapterError(reyouCodeTokenInvalid, "signature invalid", false, providerOrderID)
}
coin, coinOK := reyouInt64(coinText)
opValue, typeOK := reyouInt32(typeText)
opType := reyouOpType(opValue)
if providerOrderID == "" || gameID == "" || uid == "" || token == "" || !coinOK || !typeOK || opType == "" || coin < 0 {
return reyouAdapterError(reyouCodeTokenInvalid, "invalid payload", true, providerOrderID)
}
config := reyouConfigFromPlatform(platform)
session, err := s.validReyouSession(ctx, app, token)
// 热游同时带 token、uid 和 gameId三者必须落到同一个启动会话JWT 直连时允许 gameId 后置补齐。
if err != nil || !reyouSessionMatches(session, config, uid, gameID) {
return reyouAdapterError(reyouCodeTokenInvalid, "token invalid", true, providerOrderID)
}
if session.ProviderGameID == "" {
session.ProviderGameID = gameID
}
if session.GameID == "" {
session.GameID = gameID
}
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, roundID, opType, coin, "", requestHash)
if err != nil {
code := reyouCodeFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
raw, contentType := reyouJSON(code, reyouMessageFromError(err), map[string]any{"coin": balance, "responseId": ""})
return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil
}
if result.IdempotentReplay {
// 热游文档要求重复 orderId 明确返回 3001这里只回历史余额不再次触发钱包改账。
raw, contentType := reyouJSON(reyouCodeDuplicatedOrder, "duplicated order", map[string]any{"coin": result.BalanceAfter, "responseId": reyouResponseID(app, req.GetPlatformCode(), providerOrderID)})
return raw, contentType, strconv.Itoa(reyouCodeDuplicatedOrder), true, providerOrderID, nil
}
raw, contentType := reyouJSON(reyouCodeOK, "", map[string]any{"coin": result.BalanceAfter, "responseId": reyouResponseID(app, req.GetPlatformCode(), providerOrderID)})
return raw, contentType, strconv.Itoa(reyouCodeOK), true, providerOrderID, nil
}
func (s *Service) validReyouSession(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")
}
// App 当前把 access token 作为三方 tokenJWT 要优先解析,避免旧 launch session 同 hash 但旧 gameId 误判。
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 reyouSessionMatches(session gamedomain.LaunchSession, config reyouAdapterConfig, uid string, gameID string) bool {
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
uid = strings.TrimSpace(uid)
return uid == externalUserID(session, config.UIDMode) ||
uid == strings.TrimSpace(session.DisplayUserID) ||
uid == strconv.FormatInt(session.UserID, 10)
}
func reyouOpType(value int32) string {
// 热游 type=1 表示扣减type=2 表示增加;下游钱包只接受 debit/credit。
switch value {
case 1:
return "debit"
case 2:
return "credit"
default:
return ""
}
}
func decodeReyouJSON(raw []byte, out any) error {
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
return decoder.Decode(out)
}
func reyouJSONText(raw json.RawMessage) string {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return ""
}
var decoded string
if err := json.Unmarshal(raw, &decoded); err == nil {
return strings.TrimSpace(decoded)
}
return text
}
func reyouInt64(value string) (int64, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed, err == nil
}
func reyouInt32(value string) (int32, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 32)
return int32(parsed), err == nil
}
func reyouSignValid(actual string, key string, parts ...string) bool {
key = strings.TrimSpace(key)
if key == "" || strings.TrimSpace(actual) == "" {
return false
}
builder := strings.Builder{}
for _, part := range parts {
builder.WriteString(strings.TrimSpace(part))
}
builder.WriteString(key)
sum := md5.Sum([]byte(builder.String()))
expected := strings.ToUpper(hex.EncodeToString(sum[:]))
return strings.EqualFold(strings.TrimSpace(actual), expected)
}
func reyouResponseID(app string, platformCode string, providerOrderID string) string {
return "reyou_" + stableHash(app+"|"+platformCode+"|"+providerOrderID)
}
func reyouAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
raw, contentType := reyouJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}
func reyouJSON(errorCode int, message string, data any) ([]byte, string) {
payload := map[string]any{"errorCode": errorCode}
if data != nil {
payload["data"] = data
}
if strings.TrimSpace(message) != "" {
payload["message"] = strings.TrimSpace(message)
}
return jsonResponse(payload)
}
func reyouCodeFromError(err error) int {
switch {
case err == nil:
return reyouCodeOK
case xerr.IsCode(err, xerr.InsufficientBalance):
return reyouCodeInsufficientBalance
case xerr.IsCode(err, xerr.IdempotencyConflict):
return reyouCodeDuplicatedOrder
default:
return reyouCodeTokenInvalid
}
}
func reyouMessageFromError(err error) string {
switch reyouCodeFromError(err) {
case reyouCodeInsufficientBalance:
return "coin not enough"
case reyouCodeDuplicatedOrder:
return "duplicated order"
default:
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
return xerr.MessageOf(err)
}
return "token invalid"
}
}