2026-05-20 18:07:33 +08:00

533 lines
19 KiB
Go

package game
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"net"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const (
yomiCodeOK = 200
yomiCodeSystemError = 9998
yomiCodeInsufficientBalance = 10003
yomiCodeGameMaintenance = 10004
yomiCodeTokenInvalid = 10005
yomiCodeSignatureInvalid = 10007
yomiCodeGameNotFound = 10018
yomiCodeUserNotFound = 10019
yomiCodeIPForbidden = 10020
yomiDefaultTokenTTL = 24 * time.Hour
)
type yomiAdapterConfig struct {
UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
GameLevelUID int64 `json:"game_level_uid"`
}
func yomiConfigFromPlatform(value any) yomiAdapterConfig {
var raw string
switch typed := value.(type) {
case gamedomain.Platform:
raw = typed.AdapterConfigJSON
case gamedomain.LaunchableGame:
raw = typed.AdapterConfigJSON
}
var config yomiAdapterConfig
_ = 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 yomiAdapterConfig) TokenTTL() time.Duration {
if c.TokenTTLSeconds > 0 {
return time.Duration(c.TokenTTLSeconds) * time.Second
}
return yomiDefaultTokenTTL
}
func externalUserID(session gamedomain.LaunchSession, uidMode string) string {
if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" {
return strings.TrimSpace(session.DisplayUserID)
}
return strconv.FormatInt(session.UserID, 10)
}
func (s *Service) handleYomiOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{})
return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil
}
body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext)
if err != nil {
raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{})
return raw, contentType, strconv.Itoa(yomiCodeSignatureInvalid), false, "", nil
}
switch operation {
case "gettoken", "get_token", "api/gettoken":
return s.handleYomiGetToken(ctx, app, platform, req, body)
case "userinfo", "user_info", "api/userinfo":
return s.handleYomiUserInfo(ctx, app, platform, req, body)
case "change_balance", "changebalance", "api/change_balance":
return s.handleYomiChangeBalance(ctx, app, platform, req, body, requestHash)
case "repair_order", "repairorder", "api/repair_order":
return s.handleYomiRepairOrder(ctx, app, platform, body)
default:
raw, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{})
return raw, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
}
}
func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
var body struct {
GameUID json.Number `json:"gameUid"`
Lang string `json:"lang"`
PlatAuthCode string `json:"platAuthCode"`
PlatUserID string `json:"platUserId"`
PlatPayload string `json:"platPayload"`
PlatRoomID string `json:"platRoomId"`
}
if err := decodeYomiJSON(raw, &body); err != nil {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
}
config := yomiConfigFromPlatform(platform)
session, err := s.validYomiSession(ctx, app, body.PlatAuthCode)
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
}
data := map[string]any{
"platUserId": body.PlatUserID,
"platUserOpenId": session.DisplayUserID,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
"balance": snapshot.Balance,
"platToken": body.PlatAuthCode,
"expireAt": session.ExpiresAtMS / 1000,
}
rawResp, contentType := yomiJSON(yomiCodeOK, "success", data)
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
}
func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
var body struct {
PlatUserID string `json:"platUserId"`
PlatToken string `json:"platToken"`
PlatPayload string `json:"platPayload"`
PlatRoomID string `json:"platRoomId"`
}
if err := decodeYomiJSON(raw, &body); err != nil {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
}
config := yomiConfigFromPlatform(platform)
session, err := s.validYomiSession(ctx, app, body.PlatToken)
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") {
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
}
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil {
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
}
data := map[string]any{
"platUserId": body.PlatUserID,
"platUserOpenId": session.DisplayUserID,
"nickname": snapshot.Nickname,
"avatar": snapshot.Avatar,
"balance": snapshot.Balance,
}
rawResp, contentType := yomiJSON(yomiCodeOK, "success", data)
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
}
func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) {
var body struct {
RequestID json.Number `json:"requestId"`
PlatUserID string `json:"platUserId"`
PlatToken string `json:"platToken"`
Gold int64 `json:"gold"`
GameUID json.Number `json:"gameUid"`
GameLevelUID json.Number `json:"gameLevelUid"`
ReasonType int32 `json:"reasonType"`
GameRoundID json.Number `json:"gameRoundId"`
Timestamp int64 `json:"timestamp"`
PlatPayload string `json:"platPayload"`
PlatRoomID string `json:"platRoomId"`
GameInfos []any `json:"game_infos"`
}
if err := decodeYomiJSON(raw, &body); err != nil {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
}
providerOrderID := strings.TrimSpace(body.RequestID.String())
config := yomiConfigFromPlatform(platform)
session, err := s.validYomiSession(ctx, app, body.PlatToken)
if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID)
}
opType := yomiOpType(body.ReasonType)
if providerOrderID == "" || opType == "" || body.Gold < 0 {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
}
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash)
if err != nil {
code := yomiCodeFromError(err)
message := yomiMessageFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
rawResp, contentType := yomiJSON(code, message, map[string]any{"balance": balance})
return rawResp, contentType, strconv.Itoa(code), true, providerOrderID, nil
}
rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{"balance": result.BalanceAfter})
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil
}
func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) {
var body struct {
RequestID json.Number `json:"requestId"`
OrderID string `json:"orderId"`
PlatUserID string `json:"platUserId"`
Gold int64 `json:"gold"`
GameUID json.Number `json:"gameUid"`
GameRoundID json.Number `json:"gameRoundId"`
RewardType int32 `json:"rewardType"`
WinID string `json:"winId"`
PlatRoomID string `json:"platRoomId"`
}
if err := decodeYomiJSON(raw, &body); err != nil {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
}
providerOrderID := strings.TrimSpace(body.OrderID)
if providerOrderID == "" {
providerOrderID = strings.TrimSpace(body.RequestID.String())
}
if providerOrderID == "" || strings.TrimSpace(body.PlatUserID) == "" || strings.TrimSpace(body.GameUID.String()) == "" || body.Gold < 0 {
return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
}
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
AppCode: app,
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID),
PlatformCode: platform.PlatformCode,
ProviderOrderID: providerOrderID,
Reason: "yomi_repair_order",
Status: gamedomain.RepairStatusLogged,
})
if err != nil {
return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, providerOrderID)
}
rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{})
return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil
}
type yomiCoinChangeResult struct {
BalanceAfter int64
}
func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) {
order := gamedomain.GameOrder{
AppCode: app,
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID),
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
ProviderOrderID: providerOrderID,
ProviderRoundID: providerRoundID,
GameID: session.GameID,
ProviderGameID: session.ProviderGameID,
UserID: session.UserID,
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
OpType: opType,
CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying,
RequestHash: requestHash,
}
order, exists, err := s.repository.CreateGameOrder(ctx, order)
if err != nil {
return yomiCoinChangeResult{}, err
}
if exists && order.Status == gamedomain.OrderStatusSucceeded {
return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter}, nil
}
if amount == 0 {
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil {
return yomiCoinChangeResult{}, err
}
return yomiCoinChangeResult{BalanceAfter: balance}, nil
}
if s.wallet == nil {
return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + providerOrderID,
UserId: session.UserID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
GameId: session.GameID,
ProviderOrderId: providerOrderID,
ProviderRoundId: providerRoundID,
OpType: opType,
CoinAmount: amount,
RoomId: firstNonEmpty(roomID, session.RoomID),
RequestHash: requestHash,
})
nowMs := s.now().UnixMilli()
if err != nil {
status := gamedomain.OrderStatusFailed
if xerr.IsCode(err, xerr.IdempotencyConflict) {
status = gamedomain.OrderStatusConflict
}
_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
return yomiCoinChangeResult{}, err
}
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
return yomiCoinChangeResult{}, err
}
return yomiCoinChangeResult{BalanceAfter: walletResp.GetBalanceAfter()}, nil
}
type yomiUserSnapshot struct {
Nickname string
Avatar string
Balance int64
}
func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) {
if s.user == nil || s.wallet == nil {
return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: req.GetMeta().GetRequestId(),
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: app,
},
UserId: userID,
})
if err != nil {
return yomiUserSnapshot{}, err
}
return yomiUserSnapshot{
Nickname: userResp.GetUser().GetUsername(),
Avatar: userResp.GetUser().GetAvatar(),
Balance: s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), userID),
}, nil
}
func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 {
if s.wallet == nil || userID <= 0 {
return 0
}
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: requestID,
AppCode: app,
UserId: userID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return 0
}
for _, item := range resp.GetBalances() {
if item.GetAssetType() == "COIN" {
return item.GetAvailableAmount()
}
}
return 0
}
func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
if strings.TrimSpace(token) == "" {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
}
session, err := s.repository.GetLaunchSessionByToken(ctx, app, token)
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 yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool {
if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) {
return false
}
expectedUserID := externalUserID(session, config.UIDMode)
return strings.TrimSpace(platUserID) == expectedUserID
}
func yomiOpType(reasonType int32) string {
switch reasonType {
case 1:
return "debit"
case 2:
return "credit"
default:
return ""
}
}
func decodeYomiJSON(raw []byte, out any) error {
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
return decoder.Decode(out)
}
func decryptYomiBody(raw []byte, secret string) ([]byte, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty")
}
decoded, err := decodeYomiBase64(strings.TrimSpace(string(raw)))
if err != nil {
return nil, err
}
block, err := aes.NewCipher([]byte(secret))
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(decoded) <= gcm.NonceSize() {
return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short")
}
nonce := decoded[:gcm.NonceSize()]
ciphertext := decoded[gcm.NonceSize():]
return gcm.Open(nil, nonce, ciphertext, nil)
}
func decodeYomiBase64(value string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.URLEncoding,
base64.RawURLEncoding,
base64.StdEncoding,
base64.RawStdEncoding,
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(value)
if err == nil {
return decoded, nil
}
lastErr = err
}
return nil, lastErr
}
func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool {
if len(whitelist) == 0 {
return true
}
ip := net.ParseIP(callbackRequestIP(req))
if ip == nil {
return false
}
for _, raw := range whitelist {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if _, network, err := net.ParseCIDR(value); err == nil && network.Contains(ip) {
return true
}
if allowed := net.ParseIP(value); allowed != nil && allowed.Equal(ip) {
return true
}
}
return false
}
func callbackRequestIP(req *gamev1.CallbackRequest) string {
for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} {
if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
return strings.TrimSpace(strings.Split(raw, ",")[0])
}
}
for _, key := range []string{"X-Real-IP", "x-real-ip"} {
if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
return raw
}
}
host, _, err := net.SplitHostPort(strings.TrimSpace(req.GetRemoteAddr()))
if err == nil {
return host
}
return strings.TrimSpace(req.GetRemoteAddr())
}
func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
raw, contentType := yomiJSON(code, message, map[string]any{})
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}
func yomiJSON(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, "message": message, "data": data})
}
func yomiCodeFromError(err error) int {
switch {
case err == nil:
return yomiCodeOK
case xerr.IsCode(err, xerr.InsufficientBalance):
return yomiCodeInsufficientBalance
case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
return yomiCodeTokenInvalid
case xerr.IsCode(err, xerr.NotFound):
return yomiCodeUserNotFound
case xerr.IsCode(err, xerr.Conflict), xerr.IsCode(err, xerr.IdempotencyConflict):
return yomiCodeSystemError
default:
return yomiCodeSystemError
}
}
func yomiMessageFromError(err error) string {
switch yomiCodeFromError(err) {
case yomiCodeInsufficientBalance:
return "gold not enough"
case yomiCodeTokenInvalid:
return "token invalid"
case yomiCodeUserNotFound:
return "user not found"
default:
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
return xerr.MessageOf(err)
}
return "system error"
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}