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