2026-07-14 20:23:12 +08:00

413 lines
15 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/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})
}