347 lines
14 KiB
Go
347 lines
14 KiB
Go
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 作为三方 token;JWT 要优先解析,避免旧 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"
|
||
}
|
||
}
|