2026-05-31 16:39:49 +08:00

612 lines
25 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/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_iddisplay_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 支持同一平台下不同游戏/房间使用不同场次 IDkey 可填 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 的闭环校验。
// 当前实现直接复用启动 tokenYomi 后续 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 ""
}