612 lines
25 KiB
Go
612 lines
25 KiB
Go
package game
|
||
|
||
import (
|
||
"context"
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"net"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
gamev1 "hyapp.local/api/proto/game/v1"
|
||
userv1 "hyapp.local/api/proto/user/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/xerr"
|
||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||
)
|
||
|
||
const (
|
||
// Yomi 返回码必须按厂商文档透传,游戏侧会直接根据这些数字决定是否重试或弹错。
|
||
yomiCodeOK = 200
|
||
yomiCodeSystemError = 9998
|
||
yomiCodeInsufficientBalance = 10003
|
||
yomiCodeGameMaintenance = 10004
|
||
yomiCodeTokenInvalid = 10005
|
||
yomiCodeSignatureInvalid = 10007
|
||
yomiCodeGameNotFound = 10018
|
||
yomiCodeUserNotFound = 10019
|
||
yomiCodeIPForbidden = 10020
|
||
|
||
yomiDefaultTokenTTL = 24 * time.Hour
|
||
)
|
||
|
||
// yomiAdapterConfig 是后台 adapter_config_json 的结构化视图。
|
||
// 这类厂商字段必须放在 DB 配置里,后续切测试/正式环境或新增游戏档位无需重启服务。
|
||
type yomiAdapterConfig struct {
|
||
// uid_mode 控制给厂商的用户标识来源:
|
||
// 空或 user_id 使用内部数字 user_id;display_user_id 使用 App 展示 ID。
|
||
UIDMode string `json:"uid_mode"`
|
||
DefaultLang string `json:"default_lang"`
|
||
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
||
// game_level_uid 对应 Yomi 房间/场次,没有配置时按文档传 0。
|
||
GameLevelUID int64 `json:"game_level_uid"`
|
||
// game_level_uids 支持同一平台下不同游戏/房间使用不同场次 ID,key 可填 providerGameId 或内部 gameId。
|
||
GameLevelUIDs map[string]int64 `json:"game_level_uids"`
|
||
// plat_payload 透传给 Yomi,适合联调时放环境、渠道或房间附加上下文;不参与服务端业务判断。
|
||
PlatPayload string `json:"plat_payload"`
|
||
}
|
||
|
||
func yomiConfigFromPlatform(value any) yomiAdapterConfig {
|
||
// 启动链路拿到的是 LaunchableGame,回调链路拿到的是 Platform;这里统一解析,避免两处配置规则漂移。
|
||
var raw string
|
||
switch typed := value.(type) {
|
||
case gamedomain.Platform:
|
||
raw = typed.AdapterConfigJSON
|
||
case gamedomain.LaunchableGame:
|
||
raw = typed.AdapterConfigJSON
|
||
}
|
||
var config yomiAdapterConfig
|
||
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
||
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
||
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
||
config.PlatPayload = strings.TrimSpace(config.PlatPayload)
|
||
config.GameLevelUIDs = normalizeInt64Map(config.GameLevelUIDs)
|
||
return config
|
||
}
|
||
|
||
func (c yomiAdapterConfig) TokenTTL() time.Duration {
|
||
// Yomi 文档建议 token 至少 24 小时;后台可以调大或调小,但空配置必须有安全默认值。
|
||
if c.TokenTTLSeconds > 0 {
|
||
return time.Duration(c.TokenTTLSeconds) * time.Second
|
||
}
|
||
return yomiDefaultTokenTTL
|
||
}
|
||
|
||
func (c yomiAdapterConfig) GameLevelUIDFor(game gamedomain.LaunchableGame) int64 {
|
||
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
|
||
if value := c.GameLevelUIDs[strings.TrimSpace(key)]; value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return c.GameLevelUID
|
||
}
|
||
|
||
func normalizeInt64Map(input map[string]int64) map[string]int64 {
|
||
if len(input) == 0 {
|
||
return nil
|
||
}
|
||
output := make(map[string]int64, len(input))
|
||
for key, value := range input {
|
||
key = strings.TrimSpace(key)
|
||
if key != "" && value > 0 {
|
||
output[key] = value
|
||
}
|
||
}
|
||
if len(output) == 0 {
|
||
return nil
|
||
}
|
||
return output
|
||
}
|
||
|
||
func externalUserID(session gamedomain.LaunchSession, uidMode string) string {
|
||
// 三方通常会把 uid 展示给玩家或写入订单,切换 uid_mode 后必须保持启动和回调校验一致。
|
||
if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" {
|
||
return strings.TrimSpace(session.DisplayUserID)
|
||
}
|
||
return strconv.FormatInt(session.UserID, 10)
|
||
}
|
||
|
||
func (s *Service) handleYomiOperation(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) {
|
||
raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{})
|
||
return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil
|
||
}
|
||
// Yomi 请求体是 JSON -> AES-GCM -> base64 URL 编码,body 解不开就按签名错误返回。
|
||
body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext)
|
||
if err != nil {
|
||
raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{})
|
||
return raw, contentType, strconv.Itoa(yomiCodeSignatureInvalid), false, "", nil
|
||
}
|
||
|
||
switch operation {
|
||
// operation 来自网关路由尾段,不同部署可能保留 api/ 前缀,所以兼容常见写法。
|
||
case "gettoken", "get_token", "api/gettoken":
|
||
return s.handleYomiGetToken(ctx, app, platform, req, body)
|
||
case "userinfo", "user_info", "api/userinfo":
|
||
return s.handleYomiUserInfo(ctx, app, platform, req, body)
|
||
case "change_balance", "changebalance", "api/change_balance":
|
||
return s.handleYomiChangeBalance(ctx, app, platform, req, body, requestHash)
|
||
case "repair_order", "repairorder", "api/repair_order":
|
||
return s.handleYomiRepairOrder(ctx, app, platform, body)
|
||
default:
|
||
raw, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{})
|
||
return raw, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
|
||
}
|
||
}
|
||
|
||
func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
|
||
// gettoken 是 Yomi 用启动链接里的 platAuthCode 换平台 token 的闭环校验。
|
||
// 当前实现直接复用启动 token:Yomi 后续 userinfo/change_balance 都必须带同一个 platToken。
|
||
var body struct {
|
||
GameUID json.Number `json:"gameUid"`
|
||
Lang string `json:"lang"`
|
||
PlatAuthCode string `json:"platAuthCode"`
|
||
PlatUserID string `json:"platUserId"`
|
||
PlatPayload string `json:"platPayload"`
|
||
PlatRoomID string `json:"platRoomId"`
|
||
}
|
||
if err := decodeYomiJSON(raw, &body); err != nil {
|
||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
||
}
|
||
config := yomiConfigFromPlatform(platform)
|
||
session, err := s.validYomiSession(ctx, app, body.PlatAuthCode)
|
||
// 同时校验 token、用户和游戏,避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。
|
||
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
|
||
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
|
||
}
|
||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||
if err != nil {
|
||
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
|
||
}
|
||
data := map[string]any{
|
||
// platUserId 必须原样返回,Yomi 文档明确要求保持业务含义稳定。
|
||
"platUserId": body.PlatUserID,
|
||
"platUserOpenId": session.DisplayUserID,
|
||
"nickname": snapshot.Nickname,
|
||
"avatar": snapshot.Avatar,
|
||
"balance": snapshot.Balance,
|
||
"platToken": body.PlatAuthCode,
|
||
"expireAt": session.ExpiresAtMS / 1000,
|
||
}
|
||
rawResp, contentType := yomiJSON(yomiCodeOK, "success", data)
|
||
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
|
||
}
|
||
|
||
func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
|
||
// userinfo 是游戏侧进入房间或中途刷新玩家资料时调用,只读用户资料和当前金币。
|
||
var body struct {
|
||
PlatUserID string `json:"platUserId"`
|
||
PlatToken string `json:"platToken"`
|
||
PlatPayload string `json:"platPayload"`
|
||
PlatRoomID string `json:"platRoomId"`
|
||
}
|
||
if err := decodeYomiJSON(raw, &body); err != nil {
|
||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
||
}
|
||
config := yomiConfigFromPlatform(platform)
|
||
session, err := s.validYomiSession(ctx, app, body.PlatToken)
|
||
// userinfo 没有 gameUid 时只校验用户;change_balance 会额外校验游戏 ID。
|
||
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") {
|
||
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
|
||
}
|
||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||
if err != nil {
|
||
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
|
||
}
|
||
data := map[string]any{
|
||
"platUserId": body.PlatUserID,
|
||
"platUserOpenId": session.DisplayUserID,
|
||
"nickname": snapshot.Nickname,
|
||
"avatar": snapshot.Avatar,
|
||
"balance": snapshot.Balance,
|
||
}
|
||
rawResp, contentType := yomiJSON(yomiCodeOK, "success", data)
|
||
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
|
||
}
|
||
|
||
func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) {
|
||
// change_balance 是唯一真正改钱包的 Yomi 回调;requestId 是厂商幂等键,必须落库去重。
|
||
var body struct {
|
||
RequestID json.Number `json:"requestId"`
|
||
PlatUserID string `json:"platUserId"`
|
||
PlatToken string `json:"platToken"`
|
||
Gold int64 `json:"gold"`
|
||
GameUID json.Number `json:"gameUid"`
|
||
GameLevelUID json.Number `json:"gameLevelUid"`
|
||
ReasonType int32 `json:"reasonType"`
|
||
GameRoundID json.Number `json:"gameRoundId"`
|
||
Timestamp int64 `json:"timestamp"`
|
||
PlatPayload string `json:"platPayload"`
|
||
PlatRoomID string `json:"platRoomId"`
|
||
GameInfos []any `json:"game_infos"`
|
||
}
|
||
if err := decodeYomiJSON(raw, &body); err != nil {
|
||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
||
}
|
||
providerOrderID := strings.TrimSpace(body.RequestID.String())
|
||
config := yomiConfigFromPlatform(platform)
|
||
session, err := s.validYomiSession(ctx, app, body.PlatToken)
|
||
// 改账必须绑定 token、uid、gameUid 三个维度,防止跨用户、跨游戏复用合法 token。
|
||
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
|
||
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID)
|
||
}
|
||
opType := yomiOpType(body.ReasonType)
|
||
// reasonType=1 扣款,reasonType=2 加款;gold=0 是免费局/未中奖的合法结算。
|
||
if providerOrderID == "" || opType == "" || body.Gold < 0 {
|
||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
|
||
}
|
||
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash)
|
||
if err != nil {
|
||
// 厂商要求失败也返回当前余额,查不到时 bestEffortCoinBalance 会退化为 0。
|
||
code := yomiCodeFromError(err)
|
||
message := yomiMessageFromError(err)
|
||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||
rawResp, contentType := yomiJSON(code, message, map[string]any{"balance": balance})
|
||
return rawResp, contentType, strconv.Itoa(code), true, providerOrderID, nil
|
||
}
|
||
rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{"balance": result.BalanceAfter})
|
||
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil
|
||
}
|
||
|
||
func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) {
|
||
// 补单接口首版只做简单校验和审计落库,不直接补钱,符合当前“先写 Log”边界。
|
||
var body struct {
|
||
RequestID json.Number `json:"requestId"`
|
||
OrderID string `json:"orderId"`
|
||
PlatUserID string `json:"platUserId"`
|
||
Gold int64 `json:"gold"`
|
||
GameUID json.Number `json:"gameUid"`
|
||
GameRoundID json.Number `json:"gameRoundId"`
|
||
RewardType int32 `json:"rewardType"`
|
||
WinID string `json:"winId"`
|
||
PlatRoomID string `json:"platRoomId"`
|
||
}
|
||
if err := decodeYomiJSON(raw, &body); err != nil {
|
||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
|
||
}
|
||
providerOrderID := strings.TrimSpace(body.OrderID)
|
||
// 不同厂商/版本可能只给 requestId,这里把 orderId 和 requestId 都兼容成 providerOrderID。
|
||
if providerOrderID == "" {
|
||
providerOrderID = strings.TrimSpace(body.RequestID.String())
|
||
}
|
||
if providerOrderID == "" || strings.TrimSpace(body.PlatUserID) == "" || strings.TrimSpace(body.GameUID.String()) == "" || body.Gold < 0 {
|
||
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
|
||
}
|
||
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
|
||
AppCode: app,
|
||
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID),
|
||
PlatformCode: platform.PlatformCode,
|
||
ProviderOrderID: providerOrderID,
|
||
Reason: "yomi_repair_order",
|
||
Status: gamedomain.RepairStatusLogged,
|
||
})
|
||
if err != nil {
|
||
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, providerOrderID)
|
||
}
|
||
rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{})
|
||
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil
|
||
}
|
||
|
||
type yomiCoinChangeResult struct {
|
||
BalanceAfter int64
|
||
IdempotentReplay bool
|
||
}
|
||
|
||
func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) {
|
||
// order_id 用平台订单号稳定派生,保证厂商重试、服务重启、并发请求都命中同一条订单。
|
||
order := gamedomain.GameOrder{
|
||
AppCode: app,
|
||
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID),
|
||
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
|
||
ProviderOrderID: providerOrderID,
|
||
ProviderRoundID: providerRoundID,
|
||
GameID: session.GameID,
|
||
ProviderGameID: session.ProviderGameID,
|
||
UserID: session.UserID,
|
||
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
|
||
RegionID: session.RegionID,
|
||
OpType: opType,
|
||
CoinAmount: amount,
|
||
Status: gamedomain.OrderStatusWalletApplying,
|
||
RequestHash: requestHash,
|
||
}
|
||
order, exists, err := s.repository.CreateGameOrder(ctx, order)
|
||
if err != nil {
|
||
return yomiCoinChangeResult{}, err
|
||
}
|
||
if exists && order.Status == gamedomain.OrderStatusSucceeded {
|
||
// 已成功订单按幂等重放处理,直接返回历史余额,不能再次调用钱包。
|
||
return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter, IdempotentReplay: true}, nil
|
||
}
|
||
if amount == 0 {
|
||
// Yomi 文档允许投注/返奖金额为 0;这类订单仍需标记成功,但不能产生钱包流水。
|
||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil {
|
||
return yomiCoinChangeResult{}, err
|
||
}
|
||
return yomiCoinChangeResult{BalanceAfter: balance}, nil
|
||
}
|
||
if s.wallet == nil {
|
||
return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
// wallet-service 持有真实余额,game-service 只传入平台订单和幂等命令 ID。
|
||
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
|
||
RequestId: req.GetMeta().GetRequestId(),
|
||
AppCode: app,
|
||
CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + providerOrderID,
|
||
UserId: session.UserID,
|
||
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
|
||
GameId: session.GameID,
|
||
ProviderOrderId: providerOrderID,
|
||
ProviderRoundId: providerRoundID,
|
||
OpType: opType,
|
||
CoinAmount: amount,
|
||
RoomId: firstNonEmpty(roomID, session.RoomID),
|
||
RequestHash: requestHash,
|
||
})
|
||
nowMs := s.now().UnixMilli()
|
||
if err != nil {
|
||
status := gamedomain.OrderStatusFailed
|
||
if xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||
// 钱包侧发现幂等冲突时单独标记,便于后续运营排查重复或参数不一致订单。
|
||
status = gamedomain.OrderStatusConflict
|
||
}
|
||
_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
|
||
return yomiCoinChangeResult{}, err
|
||
}
|
||
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
|
||
return yomiCoinChangeResult{}, err
|
||
}
|
||
return yomiCoinChangeResult{BalanceAfter: walletResp.GetBalanceAfter()}, nil
|
||
}
|
||
|
||
type yomiUserSnapshot struct {
|
||
// 厂商只需要展示资料和金币,不把内部用户完整模型暴露给适配器。
|
||
Nickname string
|
||
Avatar string
|
||
Balance int64
|
||
}
|
||
|
||
func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) {
|
||
// 用户资料走 user-service,金币走 wallet-service,避免 game-service 缓存造成余额不准。
|
||
if s.user == nil || s.wallet == nil {
|
||
return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
|
||
}
|
||
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
|
||
Meta: &userv1.RequestMeta{
|
||
RequestId: req.GetMeta().GetRequestId(),
|
||
Caller: "game-service",
|
||
SentAtMs: s.now().UnixMilli(),
|
||
AppCode: app,
|
||
},
|
||
UserId: userID,
|
||
})
|
||
if err != nil {
|
||
return yomiUserSnapshot{}, err
|
||
}
|
||
return yomiUserSnapshot{
|
||
Nickname: userResp.GetUser().GetUsername(),
|
||
Avatar: userResp.GetUser().GetAvatar(),
|
||
Balance: s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), userID),
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 {
|
||
// 余额查询失败时不阻断错误响应;厂商协议更需要一个可解析的 balance 字段。
|
||
if s.wallet == nil || userID <= 0 {
|
||
return 0
|
||
}
|
||
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
|
||
RequestId: requestID,
|
||
AppCode: app,
|
||
UserId: userID,
|
||
AssetTypes: []string{"COIN"},
|
||
})
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
for _, item := range resp.GetBalances() {
|
||
if item.GetAssetType() == "COIN" {
|
||
return item.GetAvailableAmount()
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
|
||
// 启动 token 明文只出现在启动 URL 和厂商回调里,数据库里按 hash 查询。
|
||
if strings.TrimSpace(token) == "" {
|
||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||
}
|
||
session, err := s.repository.GetLaunchSessionByToken(ctx, app, token)
|
||
if err != nil {
|
||
return gamedomain.LaunchSession{}, err
|
||
}
|
||
if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() {
|
||
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
|
||
}
|
||
return session, nil
|
||
}
|
||
|
||
func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool {
|
||
// gameUID 允许为空是为了兼容 userinfo;只要厂商传了就必须和启动会话里的 provider_game_id 一致。
|
||
if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) {
|
||
return false
|
||
}
|
||
expectedUserID := externalUserID(session, config.UIDMode)
|
||
return strings.TrimSpace(platUserID) == expectedUserID
|
||
}
|
||
|
||
func yomiOpType(reasonType int32) string {
|
||
// 转成钱包服务理解的 debit/credit,厂商枚举不向下游扩散。
|
||
switch reasonType {
|
||
case 1:
|
||
return "debit"
|
||
case 2:
|
||
return "credit"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func decodeYomiJSON(raw []byte, out any) error {
|
||
// UseNumber 避免 requestId/gameRoundId 这类 64 位数字经过 float64 丢精度。
|
||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||
decoder.UseNumber()
|
||
return decoder.Decode(out)
|
||
}
|
||
|
||
func decryptYomiBody(raw []byte, secret string) ([]byte, error) {
|
||
// 文档给的 AppSecret 是 32 字节字符串,正好对应 AES-256 key。
|
||
secret = strings.TrimSpace(secret)
|
||
if secret == "" {
|
||
return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty")
|
||
}
|
||
decoded, err := decodeYomiBase64(strings.TrimSpace(string(raw)))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
block, err := aes.NewCipher([]byte(secret))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(decoded) <= gcm.NonceSize() {
|
||
return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short")
|
||
}
|
||
// Yomi 把 IV/nonce 放在密文前 12 位,剩余部分是 GCM ciphertext+tag。
|
||
nonce := decoded[:gcm.NonceSize()]
|
||
ciphertext := decoded[gcm.NonceSize():]
|
||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||
}
|
||
|
||
func decodeYomiBase64(value string) ([]byte, error) {
|
||
// 文档写 URL base64,但测试工具经常混用 padded/raw/std,这里按宽松解析提升联调成功率。
|
||
encodings := []*base64.Encoding{
|
||
base64.URLEncoding,
|
||
base64.RawURLEncoding,
|
||
base64.StdEncoding,
|
||
base64.RawStdEncoding,
|
||
}
|
||
var lastErr error
|
||
for _, encoding := range encodings {
|
||
decoded, err := encoding.DecodeString(value)
|
||
if err == nil {
|
||
return decoded, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
return nil, lastErr
|
||
}
|
||
|
||
func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool {
|
||
// 空白名单表示不限制;配置后支持单 IP 和 CIDR,便于同时兼容测试环境和正式机房。
|
||
if len(whitelist) == 0 {
|
||
return true
|
||
}
|
||
ip := net.ParseIP(callbackRequestIP(req))
|
||
if ip == nil {
|
||
return false
|
||
}
|
||
for _, raw := range whitelist {
|
||
value := strings.TrimSpace(raw)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if _, network, err := net.ParseCIDR(value); err == nil && network.Contains(ip) {
|
||
return true
|
||
}
|
||
if allowed := net.ParseIP(value); allowed != nil && allowed.Equal(ip) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func callbackRequestIP(req *gamev1.CallbackRequest) string {
|
||
// 网关会把真实来源 IP 写在转发头里;优先取第一段 X-Forwarded-For。
|
||
for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} {
|
||
if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
|
||
return strings.TrimSpace(strings.Split(raw, ",")[0])
|
||
}
|
||
}
|
||
for _, key := range []string{"X-Real-IP", "x-real-ip"} {
|
||
if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
|
||
return raw
|
||
}
|
||
}
|
||
host, _, err := net.SplitHostPort(strings.TrimSpace(req.GetRemoteAddr()))
|
||
if err == nil {
|
||
return host
|
||
}
|
||
return strings.TrimSpace(req.GetRemoteAddr())
|
||
}
|
||
|
||
func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
|
||
// 返回值同时服务“HTTP 响应”和“回调日志”,所以这里保留签名状态和平台请求号。
|
||
raw, contentType := yomiJSON(code, message, map[string]any{})
|
||
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
|
||
}
|
||
|
||
func yomiJSON(code int, message string, data any) ([]byte, string) {
|
||
// Yomi 成功 message 固定 success;失败 message 给具体错误原因,data 必须始终是对象。
|
||
if strings.TrimSpace(message) == "" {
|
||
message = "success"
|
||
}
|
||
if data == nil {
|
||
data = map[string]any{}
|
||
}
|
||
return jsonResponse(map[string]any{"code": code, "message": message, "data": data})
|
||
}
|
||
|
||
func yomiCodeFromError(err error) int {
|
||
// 内部错误码在适配器边界转换成厂商错误码,避免污染业务服务的错误模型。
|
||
switch {
|
||
case err == nil:
|
||
return yomiCodeOK
|
||
case xerr.IsCode(err, xerr.InsufficientBalance):
|
||
return yomiCodeInsufficientBalance
|
||
case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
|
||
return yomiCodeTokenInvalid
|
||
case xerr.IsCode(err, xerr.NotFound):
|
||
return yomiCodeUserNotFound
|
||
case xerr.IsCode(err, xerr.Conflict), xerr.IsCode(err, xerr.IdempotencyConflict):
|
||
return yomiCodeSystemError
|
||
default:
|
||
return yomiCodeSystemError
|
||
}
|
||
}
|
||
|
||
func yomiMessageFromError(err error) string {
|
||
switch yomiCodeFromError(err) {
|
||
case yomiCodeInsufficientBalance:
|
||
return "gold not enough"
|
||
case yomiCodeTokenInvalid:
|
||
return "token invalid"
|
||
case yomiCodeUserNotFound:
|
||
return "user not found"
|
||
default:
|
||
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
|
||
return xerr.MessageOf(err)
|
||
}
|
||
return "system error"
|
||
}
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
// roomId 这类字段优先使用回调里的实时值,缺失时回退启动会话里的值。
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|