2026-05-22 11:57:04 +08:00

365 lines
16 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, "")
}
if leaderccTokenOnlyProbe(body.Token, body.GameID, body.UID, body.RoomID, body.Sign) {
if _, err := s.validLeaderCCSession(ctx, app, body.Token); err != nil {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "")
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, "", nil
}
// 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)
if leaderccTokenOnlyProbe(body.Token, body.OrderID, body.GameID, body.RoundID, body.UID, body.WinID, body.RoomID, body.Sign) && body.Coin == 0 && body.Type == 0 && body.RewardType == 0 {
if _, err := s.validLeaderCCSession(ctx, app, body.Token); err != nil {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
// 文档签名顺序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)
}
if session.ProviderGameID == "" {
session.ProviderGameID = strings.TrimSpace(body.GameID)
}
if session.GameID == "" {
session.GameID = firstNonEmpty(strings.TrimSpace(body.GameID), "leadercc")
}
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
}
if result.IdempotentReplay {
// 灵仙要求“成功订单的重复通知”也显式返回 10003这里不重复改账只回历史余额。
raw, contentType := leaderccJSON(leaderccCodeDuplicatedOrder, leaderccMessageFromError(xerr.New(xerr.IdempotencyConflict, "duplicated order")), map[string]any{"coin": result.BalanceAfter})
return raw, contentType, strconv.Itoa(leaderccCodeDuplicatedOrder), 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"`
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)
if leaderccTokenOnlyProbe(body.Token, body.OrderID, body.GameID, body.RoundID, body.UID, body.WinID, body.RoomID, body.Sign) && body.Coin == 0 && body.RewardType == 0 {
if _, err := s.validLeaderCCSession(ctx, app, body.Token); err != nil {
return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID)
}
raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{})
return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil
}
// 文档签名顺序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) {
// 灵仙回调直接使用 App 登录 access token无需预先进入游戏创建 launch session。
if strings.Count(strings.TrimSpace(token), ".") == 2 {
return s.appSessionFromAccessToken(app, token)
}
// 兼容历史联调 token老链路会先 launch再按 game_launch_sessions 校验。
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 {
// 使用 App token 时 session.ProviderGameID 可能为空,此时不强制要求先配置/启动游戏目录。
if strings.TrimSpace(gameID) != "" && strings.TrimSpace(session.ProviderGameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
normalizedUID := strings.TrimSpace(uid)
return normalizedUID == externalUserID(session, config.UIDMode) ||
normalizedUID == strings.TrimSpace(session.DisplayUserID) ||
normalizedUID == strconv.FormatInt(session.UserID, 10)
}
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 leaderccTokenOnlyProbe(token string, parts ...string) bool {
if strings.TrimSpace(token) == "" {
return false
}
for _, part := range parts {
if strings.TrimSpace(part) != "" {
return false
}
}
return true
}
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{"code": errorCode, "errorCode": errorCode}
if data != nil {
payload["data"] = data
}
if errorCode == leaderccCodeOK && strings.TrimSpace(message) == "" {
message = "ok"
}
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"
}
}