2026-05-21 11:52:06 +08:00

310 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 (
// 灵仙接口使用 errorCode 作为业务结果HTTP 成功不代表订单成功。
leaderccCodeOK = 0
leaderccCodeTimeout = 4000
leaderccCodeInsufficientBalance = 4004
leaderccCodeInvalidArgument = 4005
leaderccCodeTokenInvalid = 10001
leaderccCodeRiskRejected = 10002
leaderccCodeDuplicatedOrder = 10003
leaderccCodeSignatureInvalid = 10004
leaderccDefaultTokenTTL = 24 * time.Hour
)
// leaderccAdapterConfig 对应后台平台配置里的 adapter_config_json。
// 目前只放跨环境可调整字段,避免每接一个灵仙游戏或切域名都发版。
type leaderccAdapterConfig struct {
// uid_mode 必须和启动 URL、userinfo、change_coin 三处保持一致。
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
}
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
// Platform 用于回调LaunchableGame 用于启动;两处共用同一套 JSON 解析规则。
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
raw = typed.AdapterConfigJSON
case gamedomain.LaunchableGame:
raw = typed.AdapterConfigJSON
}
var config leaderccAdapterConfig
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
return config
}
func (c leaderccAdapterConfig) TokenTTL() time.Duration {
// 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
return leaderccDefaultTokenTTL
}
func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
// 灵仙不做 IP 白名单过滤,来源校验只依赖每个接口的 MD5 签名和 token/session 绑定。
switch operation {
// operation 由统一 callback 路由传入,兼容不同命名可以减少网关层特殊分支。
case "userinfo", "user_info", "get_user_info":
return s.handleLeaderCCUserInfo(ctx, app, platform, req)
case "change_coin", "change_balance", "update_coin":
return s.handleLeaderCCChangeCoin(ctx, app, platform, req, requestHash)
case "repair_order", "repair", "repair_coin":
return s.handleLeaderCCRepairOrder(ctx, app, platform, req)
default:
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), false, "", nil
}
}
func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
// userinfo 签名顺序来自灵仙文档gameId + uid + token + roomId + key。
var body struct {
GameID string `json:"gameId"`
UID string `json:"uid"`
Token string `json:"token"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
}
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
// key 存在 callback_secret 字段里;后台不回显明文,更新时才覆盖。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.GameID, body.UID, body.Token, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, "")
}
config := leaderccConfigFromPlatform(platform)
session, err := s.validLeaderCCSession(ctx, app, body.Token)
// token 通过后仍要校验 uid/gameId防止合法 token 被跨玩家或跨游戏复用。
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, "")
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{
// 灵仙字段名是 coin这里直接返回钱包可用 COIN避免在适配器层引入换算。
"uid": body.UID,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
"coin": snapshot.Balance,
})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, "", nil
}
func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) {
// change_coin 是灵仙唯一改账入口orderId 是幂等键roundId 用来关联同一轮投注/返奖。
var body struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
RoundID string `json:"roundId"`
UID string `json:"uid"`
Coin int64 `json:"coin"`
Type int32 `json:"type"`
RewardType int32 `json:"rewardType"`
Token string `json:"token"`
WinID string `json:"winId"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
}
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
// 文档签名顺序orderId + gameId + roundId + uid + coin + type + rewardType + token + winId + roomId + key。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.Type), 10), strconv.FormatInt(int64(body.RewardType), 10), body.Token, body.WinID, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID)
}
config := leaderccConfigFromPlatform(platform)
session, err := s.validLeaderCCSession(ctx, app, body.Token)
// 灵仙 change_coin 既带 token 又带 uid/gameId三者都必须命中同一条启动会话。
if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
opType := leaderccOpType(body.Type)
// type=1 扣款、type=2 加款coin=0 没有实际改账,但仍会按幂等订单记成功。
if providerOrderID == "" || opType == "" || body.Coin < 0 {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID)
}
// 钱包改账和幂等逻辑与 Yomi 一致,厂商差异只停留在签名和字段映射层。
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.RoundID, opType, body.Coin, body.RoomID, requestHash)
if err != nil {
// 灵仙余额不足需要返回 4004其它内部错误在 leaderccCodeFromError 里统一映射。
code := leaderccCodeFromError(err)
message := leaderccMessageFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
raw, contentType := leaderccJSON(code, message, map[string]any{"coin": balance})
return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{"coin": result.BalanceAfter})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) {
// 补单接口必须提供给厂商,但当前阶段只记录,不自动加钱,避免绕过钱包订单幂等。
var body struct {
OrderID string `json:"orderId"`
GameID string `json:"gameId"`
RoundID string `json:"roundId"`
UID string `json:"uid"`
Coin int64 `json:"coin"`
RewardType int32 `json:"rewardType"`
WinID string `json:"winId"`
RoomID string `json:"roomId"`
Sign string `json:"sign"`
}
if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
// 文档签名顺序orderId + gameId + roundId + uid + coin + rewardType + winId + roomId + key。
if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.RewardType), 10), body.WinID, body.RoomID) {
return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID)
}
if providerOrderID == "" || strings.TrimSpace(body.GameID) == "" || strings.TrimSpace(body.UID) == "" || body.Coin < 0 {
return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID)
}
// repair_id 稳定派生,厂商重复补单会命中唯一索引并保持幂等。
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
AppCode: app,
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID),
PlatformCode: platform.PlatformCode,
ProviderOrderID: providerOrderID,
Reason: "leadercc_repair_order",
Status: gamedomain.RepairStatusLogged,
})
if err != nil {
return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, providerOrderID)
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
// 灵仙和 Yomi 都复用 game_launch_sessions差异在字段名不在 token 存储方式。
session, err := s.validYomiSession(ctx, app, token)
if err != nil {
return gamedomain.LaunchSession{}, err
}
return session, nil
}
func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAdapterConfig, uid string, gameID string) bool {
// gameId 对应内部目录里的 provider_game_iduid 取值由 uid_mode 决定。
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
return strings.TrimSpace(uid) == externalUserID(session, config.UIDMode)
}
func leaderccOpType(value int32) string {
// 厂商枚举在适配器内结束,下游钱包只认识 debit/credit。
switch value {
case 1:
return "debit"
case 2:
return "credit"
default:
return ""
}
}
func decodeLeaderCCJSON(raw []byte, out any) error {
// UseNumber 保持大订单号/局号不被 JSON float64 解析损坏,虽然当前结构体多数字段是 string。
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
return decoder.Decode(out)
}
func leaderccSignValid(actual string, key string, parts ...string) bool {
// 灵仙签名是“按文档字段顺序拼接所有字段 + key”后取 MD5 小写 hex。
key = strings.TrimSpace(key)
if key == "" {
return false
}
joined := strings.Builder{}
for _, part := range parts {
// 厂商样例没有分隔符;这里 trim 只清理传输空白,不改变字段顺序。
joined.WriteString(strings.TrimSpace(part))
}
joined.WriteString(key)
sum := md5.Sum([]byte(joined.String()))
expected := hex.EncodeToString(sum[:])
return strings.EqualFold(strings.TrimSpace(actual), expected)
}
func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
// signatureValid/providerRequestID 会写入 callback log帮助后续排查签名和重复订单问题。
raw, contentType := leaderccJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}
func leaderccJSON(errorCode int, message string, data any) ([]byte, string) {
// 灵仙成功可以不带 message失败时带 message 方便联调定位。
payload := map[string]any{"errorCode": errorCode}
if data != nil {
payload["data"] = data
}
if strings.TrimSpace(message) != "" {
payload["message"] = message
}
return jsonResponse(payload)
}
func leaderccCodeFromError(err error) int {
// 内部错误码只在适配器边界映射为灵仙错误码,业务层不依赖厂商语义。
switch {
case err == nil:
return leaderccCodeOK
case xerr.IsCode(err, xerr.InsufficientBalance):
return leaderccCodeInsufficientBalance
case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
return leaderccCodeTokenInvalid
case xerr.IsCode(err, xerr.IdempotencyConflict):
return leaderccCodeDuplicatedOrder
case xerr.IsCode(err, xerr.Unavailable):
return leaderccCodeTimeout
default:
return leaderccCodeInvalidArgument
}
}
func leaderccMessageFromError(err error) string {
switch leaderccCodeFromError(err) {
case leaderccCodeInsufficientBalance:
return "coin not enough"
case leaderccCodeTokenInvalid:
return "token invalid"
case leaderccCodeDuplicatedOrder:
return "duplicated order"
case leaderccCodeTimeout:
return "network timeout"
default:
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
return xerr.MessageOf(err)
}
return "invalid argument"
}
}