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" } }