365 lines
16 KiB
Go
365 lines
16 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 (
|
||
// 灵仙接口使用 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"
|
||
}
|
||
}
|