package game import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "fmt" "math/big" "strconv" "strings" "time" "unicode" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/xerr" gamedomain "hyapp/services/game-service/internal/domain/game" ) const ( // AMG 文档只固定成功码 0;失败码使用稳定的 HTTP 语义数字,厂商只需按 code != 0 判失败。 amgCodeOK = 0 amgCodeInvalidRequest = 400 amgCodeUnauthorized = 401 amgCodeInsufficientBalance = 402 amgCodeInternalError = 500 amgDefaultTokenTTL = 24 * time.Hour amgMaxEncryptedDataBytes = 256 * 1024 ) type amgAdapterConfig struct { // app_key 是 AMG 分配给渠道的公开标识;RSA 私钥单独存 callbackSecret,不能混入可复制的 adapter JSON。 AppKey string `json:"app_key"` UIDMode string `json:"uid_mode"` DefaultLang string `json:"default_lang"` TokenTTLSeconds int64 `json:"token_ttl_seconds"` // coin_scale 表示一个钱包最小单位对应的 AMG 小数精度;当前金币为整数时必须配置 1。 CoinScale int64 `json:"coin_scale"` // AMG 没有游戏列表接口,运营按厂商 gameId 维护每款 H5 地址,后台据此生成目录候选项。 GameURLs map[string]string `json:"game_urls"` LaunchURLTemplate string `json:"launch_url_template"` // 以下参数均来自 AMG URL 文档;零值表示不拼接,避免擅自覆盖厂商游戏自己的默认行为。 Ext string `json:"ext"` Mini int `json:"mini"` AppAudio int `json:"app_audio"` Debug int `json:"debug"` UseLang int `json:"use_lang"` Loading string `json:"loading"` } func amgConfigFromPlatform(value any) amgAdapterConfig { var raw string switch typed := value.(type) { case gamedomain.Platform: raw = typed.AdapterConfigJSON case gamedomain.LaunchableGame: raw = typed.AdapterConfigJSON } var config amgAdapterConfig _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) config.AppKey = strings.TrimSpace(config.AppKey) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) if config.UIDMode == "" { config.UIDMode = "display_user_id" } config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate) config.Ext = strings.TrimSpace(config.Ext) config.Loading = strings.TrimSpace(config.Loading) config.GameURLs = normalizeStringMap(config.GameURLs) return config } func (c amgAdapterConfig) TokenTTL() time.Duration { if c.TokenTTLSeconds > 0 { return time.Duration(c.TokenTTLSeconds) * time.Second } return amgDefaultTokenTTL } func (c amgAdapterConfig) CoinScaleValue() int64 { // 只接受十进制精度,防止 3、7 等比例在 JSON 数字中产生无限小数并造成钱包金额歧义。 scale := c.CoinScale if scale <= 0 { return 1 } for value := scale; value > 1; value /= 10 { if value%10 != 0 { return 1 } } if scale > 1_000_000 { return 1 } return scale } func amgLaunchBaseURL(game gamedomain.LaunchableGame, config amgAdapterConfig) string { for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} { if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" { return raw } } if config.LaunchURLTemplate == "" { return "" } replacer := strings.NewReplacer( "{provider_game_id}", strings.TrimSpace(game.ProviderGameID), "{providerGameId}", strings.TrimSpace(game.ProviderGameID), "{game_id}", strings.TrimSpace(game.GameID), "{gameId}", strings.TrimSpace(game.GameID), ) return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate)) } func (s *Service) handleAMGOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, _ string) ([]byte, string, string, bool, string, error) { // RSA 私钥只能证明 settlement 密文来自持有公钥的一方;来源白名单配置后仍应先挡掉非 AMG 机房请求。 if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { return amgAdapterError(amgCodeUnauthorized, "ip restricted", false, "") } switch strings.ToLower(strings.Trim(strings.TrimSpace(operation), "/")) { case "user", "amg/user": return s.handleAMGUser(ctx, app, platform, req) case "settlement", "amg/settlement": return s.handleAMGSettlement(ctx, app, platform, req) default: return amgAdapterError(amgCodeInvalidRequest, "unsupported operation", false, "") } } func (s *Service) handleAMGUser(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { token := amgAuthorizationToken(req) session, err := s.validAMGSession(ctx, app, token, "") if err != nil { return amgAdapterError(amgCodeUnauthorized, "token invalid", false, "") } snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) if err != nil { return amgAdapterError(amgCodeFromError(err), amgMessageFromError(err), true, "") } config := amgConfigFromPlatform(platform) raw, contentType := amgJSON(amgCodeOK, "success", map[string]any{ "userId": externalUserID(session, config.UIDMode), "nickname": snapshot.Nickname, "avatarUrl": snapshot.Avatar, "availableCoins": amgWalletCoinValue(snapshot.Balance, config.CoinScaleValue()), "roomId": session.RoomID, }) return raw, contentType, strconv.Itoa(amgCodeOK), true, "", nil } type amgSettlementEnvelope struct { Data string `json:"data"` } type amgSettlementData struct { TransactionID string `json:"transactionId"` Coins json.Number `json:"coins"` Type int32 `json:"type"` Timestamp int64 `json:"timestamp"` Ext string `json:"ext"` GameID json.RawMessage `json:"gameId"` } func (s *Service) handleAMGSettlement(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { var envelope amgSettlementEnvelope if err := decodeYomiJSON(req.GetRawBody(), &envelope); err != nil || strings.TrimSpace(envelope.Data) == "" || len(envelope.Data) > amgMaxEncryptedDataBytes*2 { return amgAdapterError(amgCodeInvalidRequest, "invalid data", false, "") } // AMG 的 data 是 RSA PKCS#1 v1.5 分块密文再 hex 编码;callbackSecret 保存不带 PEM 头的 PKCS#8 私钥原文。 plain, err := decryptAMGData(envelope.Data, platform.CallbackSecretCiphertext) if err != nil { return amgAdapterError(amgCodeUnauthorized, "data decrypt failed", false, "") } var body amgSettlementData if err := decodeYomiJSON(plain, &body); err != nil { return amgAdapterError(amgCodeInvalidRequest, "invalid settlement data", true, "") } body.TransactionID = strings.TrimSpace(body.TransactionID) body.Ext = strings.TrimSpace(body.Ext) gameID := reyouJSONText(body.GameID) gameIDValue, gameIDOK := amgPositiveInt64(gameID) config := amgConfigFromPlatform(platform) amount, amountOK := amgWalletCoinAmount(body.Coins, config.CoinScaleValue()) opType := yomiOpType(body.Type) if body.TransactionID == "" || len(body.TransactionID) > 64 || !gameIDOK || gameIDValue <= 0 || !amountOK || amount < 0 || opType == "" || body.Timestamp <= 0 { return amgAdapterError(amgCodeInvalidRequest, "invalid settlement data", true, body.TransactionID) } token := amgAuthorizationToken(req) session, err := s.validAMGSession(ctx, app, token, gameID) if err != nil { return amgAdapterError(amgCodeUnauthorized, "token invalid", true, body.TransactionID) } // RSA PKCS#1 v1.5 每次加密都会产生不同密文;幂等摘要必须基于解密后的业务字段,不能使用原始 callback body。 requestHash := stableHash(strings.Join([]string{ body.TransactionID, strconv.FormatInt(amount, 10), strconv.FormatInt(int64(body.Type), 10), strconv.FormatInt(body.Timestamp, 10), body.Ext, gameID, }, "|")) result, err := s.applyYomiCoinChange(ctx, app, req, session, body.TransactionID, "", opType, amount, "", requestHash) if err != nil { code := amgCodeFromError(err) balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) raw, contentType := amgJSON(code, amgMessageFromError(err), map[string]any{ "availableCoins": amgWalletCoinValue(balance, config.CoinScaleValue()), }) return raw, contentType, strconv.Itoa(code), true, body.TransactionID, nil } // 重复 transactionId 直接复用历史余额并返回成功;applyYomiCoinChange 已保证不会再次调用钱包。 raw, contentType := amgJSON(amgCodeOK, "success", map[string]any{ "availableCoins": amgWalletCoinValue(result.BalanceAfter, config.CoinScaleValue()), }) return raw, contentType, strconv.Itoa(amgCodeOK), true, body.TransactionID, nil } func (s *Service) validAMGSession(ctx context.Context, app string, token string, providerGameID string) (gamedomain.LaunchSession, error) { token = strings.TrimSpace(token) if token == "" { return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") } // 同一 App access token 可连续打开多个游戏;平台和可选 gameId 一起查询最新 session,避免串到其它厂商或旧房间。 session, err := s.repository.GetLaunchSessionByTokenScope(ctx, app, token, gamedomain.PlatformCodeAMG, strings.TrimSpace(providerGameID)) 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 amgAuthorizationToken(req *gamev1.CallbackRequest) string { if req == nil { return "" } for _, key := range []string{"Authorization", "authorization"} { value := strings.TrimSpace(req.GetHeaders()[key]) if len(value) >= len("Bearer ") && strings.EqualFold(value[:len("Bearer ")], "Bearer ") { value = strings.TrimSpace(value[len("Bearer "):]) } if value != "" { return value } } return "" } func decryptAMGData(data string, privateKeyValue string) ([]byte, error) { data = strings.TrimSpace(data) if data == "" || len(data) > amgMaxEncryptedDataBytes*2 { return nil, fmt.Errorf("AMG ciphertext is empty or too large") } ciphertext, err := hex.DecodeString(data) if err != nil || len(ciphertext) == 0 { return nil, fmt.Errorf("decode AMG ciphertext: %w", err) } privateKey, err := parseAMGPrivateKey(privateKeyValue) if err != nil { return nil, err } chunkSize := privateKey.PublicKey.Size() if len(ciphertext)%chunkSize != 0 { return nil, fmt.Errorf("AMG ciphertext chunk size is invalid") } plain := make([]byte, 0, len(ciphertext)) for start := 0; start < len(ciphertext); start += chunkSize { chunk, decryptErr := rsa.DecryptPKCS1v15(rand.Reader, privateKey, ciphertext[start:start+chunkSize]) if decryptErr != nil { return nil, fmt.Errorf("decrypt AMG ciphertext: %w", decryptErr) } plain = append(plain, chunk...) if len(plain) > amgMaxEncryptedDataBytes { return nil, fmt.Errorf("AMG plaintext is too large") } } return plain, nil } func parseAMGPrivateKey(value string) (*rsa.PrivateKey, error) { value = strings.TrimSpace(value) if value == "" { return nil, fmt.Errorf("AMG private key is empty") } var der []byte if block, _ := pem.Decode([]byte(value)); block != nil { der = block.Bytes } else { // 厂商交付文件是单行 base64 PKCS#8;容忍复制时混入的换行,但不修改实际密钥字节。 compact := strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return -1 } return r }, value) decoded, err := base64.StdEncoding.DecodeString(compact) if err != nil { return nil, fmt.Errorf("decode AMG private key: %w", err) } der = decoded } if parsed, err := x509.ParsePKCS8PrivateKey(der); err == nil { privateKey, ok := parsed.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("AMG private key is not RSA") } if err := privateKey.Validate(); err != nil { return nil, fmt.Errorf("validate AMG private key: %w", err) } return privateKey, nil } // 兼容厂商后续交付传统 PKCS#1 文件;当前 private(1).txt 已验证为 PKCS#8。 privateKey, err := x509.ParsePKCS1PrivateKey(der) if err != nil { return nil, fmt.Errorf("parse AMG private key: %w", err) } if err := privateKey.Validate(); err != nil { return nil, fmt.Errorf("validate AMG private key: %w", err) } return privateKey, nil } func amgWalletCoinAmount(value json.Number, scale int64) (int64, bool) { text := strings.TrimSpace(value.String()) if text == "" { return 0, false } rat, ok := new(big.Rat).SetString(text) if !ok { return 0, false } rat.Mul(rat, new(big.Rat).SetInt64(scale)) if !rat.IsInt() || !rat.Num().IsInt64() { return 0, false } return rat.Num().Int64(), true } func amgWalletCoinValue(value int64, scale int64) json.Number { if scale <= 1 { return json.Number(strconv.FormatInt(value, 10)) } digits := len(strconv.FormatInt(scale, 10)) - 1 negative := value < 0 abs := value if abs < 0 { abs = -abs } text := fmt.Sprintf("%d.%0*d", abs/scale, digits, abs%scale) text = strings.TrimRight(strings.TrimRight(text, "0"), ".") if negative { text = "-" + text } return json.Number(text) } func amgPositiveInt64(value string) (int64, bool) { parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return parsed, err == nil && parsed > 0 } func amgCodeFromError(err error) int { switch { case err == nil: return amgCodeOK case xerr.IsCode(err, xerr.InsufficientBalance): return amgCodeInsufficientBalance case xerr.IsCode(err, xerr.Unauthorized), xerr.IsCode(err, xerr.SessionExpired): return amgCodeUnauthorized case xerr.IsCode(err, xerr.InvalidArgument), xerr.IsCode(err, xerr.IdempotencyConflict): return amgCodeInvalidRequest default: return amgCodeInternalError } } func amgMessageFromError(err error) string { switch amgCodeFromError(err) { case amgCodeInsufficientBalance: return "insufficient balance" case amgCodeUnauthorized: return "token invalid" case amgCodeInvalidRequest: return "invalid request" default: return "system error" } } func amgAdapterError(code int, message string, authenticated bool, providerRequestID string) ([]byte, string, string, bool, string, error) { raw, contentType := amgJSON(code, message, map[string]any{}) return raw, contentType, strconv.Itoa(code), authenticated, providerRequestID, nil } func amgJSON(code int, message string, data any) ([]byte, string) { if strings.TrimSpace(message) == "" { message = "success" } if data == nil { data = map[string]any{} } return jsonResponse(map[string]any{"code": code, "data": data, "msg": message}) }