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