2026-06-02 16:05:38 +08:00

1216 lines
50 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 mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
gameservice "hyapp/services/game-service/internal/service/game"
)
// Repository 是 game-service 的 MySQL 持久化入口。
type Repository struct {
db *sql.DB
}
// Open 创建连接池并执行幂等迁移,保证本地已有数据库也能补齐游戏表。
func Open(ctx context.Context, dsn string) (*Repository, error) {
if strings.TrimSpace(dsn) == "" {
return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
repo := &Repository{db: db}
if err := repo.Migrate(ctx); err != nil {
_ = db.Close()
return nil, err
}
return repo, nil
}
func (r *Repository) Close() error {
if r == nil || r.db == nil {
return nil
}
return r.db.Close()
}
func (r *Repository) Ping(ctx context.Context) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
return r.db.PingContext(ctx)
}
// Migrate 与 initdb 保持同构;开发阶段不保留旧 schema 兼容分支。
func (r *Repository) Migrate(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS game_platforms (
app_code VARCHAR(32) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
platform_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
api_base_url VARCHAR(512) NOT NULL DEFAULT '',
adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
callback_ip_whitelist JSON NULL,
adapter_config JSON NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, platform_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_catalog (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
game_name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
min_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
tags JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id),
UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_display_rules (
app_code VARCHAR(32) NOT NULL,
rule_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
scene VARCHAR(64) NOT NULL,
region_id BIGINT NOT NULL DEFAULT 0,
language VARCHAR(32) NOT NULL DEFAULT '',
platform VARCHAR(16) NOT NULL DEFAULT '',
visible TINYINT(1) NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, rule_id),
KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_launch_sessions (
app_code VARCHAR(32) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
launch_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, session_id),
KEY idx_game_launch_user(app_code, user_id, created_at_ms),
KEY idx_game_launch_token(app_code, launch_token_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_orders (
app_code VARCHAR(32) NOT NULL,
order_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
wallet_balance_after BIGINT NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
failure_code VARCHAR(64) NOT NULL DEFAULT '',
failure_message VARCHAR(256) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, order_id),
UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL,
callback_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
request_id VARCHAR(96) NOT NULL DEFAULT '',
provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
response_code VARCHAR(64) NOT NULL DEFAULT '',
response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, callback_id),
KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_repair_orders (
app_code VARCHAR(32) NOT NULL,
repair_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
reason VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
last_error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, repair_id),
UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_level_event_outbox (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(160) NOT NULL,
order_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
game_id VARCHAR(96) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
coin_amount BIGINT NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL,
payload_json JSON NULL,
status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
locked_by VARCHAR(128) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0,
failure_reason VARCHAR(255) NOT NULL DEFAULT '',
occurred_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, event_id),
UNIQUE KEY uk_game_level_event_order(app_code, order_id),
KEY idx_game_level_event_pending(app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_game_level_event_claim(app_code, status, locked_until_ms, next_retry_at_ms, created_at_ms, event_id),
KEY idx_game_level_event_retention(app_code, status, updated_at_ms, event_id),
KEY idx_game_level_event_user(app_code, user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_outbox (
app_code VARCHAR(32) NOT NULL,
event_id VARCHAR(160) NOT NULL,
event_type VARCHAR(64) NOT NULL,
order_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
worker_id VARCHAR(128) NOT NULL DEFAULT '',
lock_until_ms BIGINT NOT NULL DEFAULT 0,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error VARCHAR(512) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, event_id),
KEY idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id),
KEY idx_game_outbox_order(app_code, order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' AFTER api_base_url"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_launch_sessions", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_orders", "region_id", "region_id BIGINT NOT NULL DEFAULT 0 AFTER room_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "lock_until_ms", "lock_until_ms BIGINT NOT NULL DEFAULT 0 AFTER worker_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "retry_count", "retry_count INT NOT NULL DEFAULT 0 AFTER lock_until_ms"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "next_retry_at_ms", "next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "last_error", "last_error VARCHAR(512) NOT NULL DEFAULT '' AFTER next_retry_at_ms"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_status_created", []string{"app_code", "status", "next_retry_at_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_status_created(app_code, status, next_retry_at_ms, created_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_lock", []string{"app_code", "status", "lock_until_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_lock(app_code, status, lock_until_ms, created_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_outbox", "idx_game_outbox_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_outbox ADD INDEX idx_game_outbox_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
return err
}
return nil
}
func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error {
var count int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
tableName, columnName,
).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` ADD COLUMN `+columnDDL)
return err
}
func (r *Repository) ensureIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, createDDL string) error {
rows, err := r.db.QueryContext(ctx,
`SELECT COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX`,
tableName, indexName,
)
if err != nil {
return err
}
defer rows.Close()
existing := make([]string, 0, len(columns))
for rows.Next() {
var column string
if err := rows.Scan(&column); err != nil {
return err
}
existing = append(existing, column)
}
if err := rows.Err(); err != nil {
return err
}
if equalStringSlices(existing, columns) {
return nil
}
if len(existing) > 0 {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName); err != nil {
return err
}
}
_, err = r.db.ExecContext(ctx, createDDL)
return err
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.min_coin, c.status, p.status,
MIN(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort
FROM game_catalog c
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id
WHERE c.app_code = ?
AND c.status IN ('active', 'maintenance')
AND p.status IN ('active', 'maintenance')
AND r.scene = ?
AND r.enabled = 1
AND r.visible = 1
AND (r.region_id = 0 OR r.region_id = ?)
AND (r.language = '' OR r.language = ?)
AND (r.platform = '' OR r.platform = ?)
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.min_coin, c.status, p.status, c.sort_order, p.sort_order
ORDER BY display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC`,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gamedomain.AppGame{}
for rows.Next() {
var item gamedomain.AppGame
var gameStatus, platformStatus string
if err := rows.Scan(&item.GameID, &item.PlatformCode, &item.Name, &item.IconURL, &item.CoverURL, &item.Category, &item.LaunchMode, &item.Orientation, &item.MinCoin, &gameStatus, &platformStatus, &item.SortOrder); err != nil {
return nil, err
}
item.NameKey = "game." + item.GameID + ".name"
item.Enabled = gameStatus == gamedomain.StatusActive && platformStatus == gamedomain.StatusActive
item.Maintenance = gameStatus == gamedomain.StatusMaintenance || platformStatus == gamedomain.StatusMaintenance
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) {
// 启动游戏时一次性取出 catalog + platform 配置,避免拿到游戏后再查平台导致配置不一致。
row := r.db.QueryRowContext(ctx,
`SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category,
c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.min_coin, c.status,
c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms,
p.platform_name, p.status, p.api_base_url, COALESCE(p.adapter_type, 'demo'),
p.callback_secret_ciphertext, COALESCE(CAST(p.callback_ip_whitelist AS CHAR), '[]'),
COALESCE(CAST(p.adapter_config AS CHAR), '{}')
FROM game_catalog c
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
WHERE c.app_code = ? AND c.game_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
var item gamedomain.LaunchableGame
var tagsJSON string
var whitelistJSON string
if err := row.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchableGame{}, xerr.New(xerr.NotFound, "game not found")
}
return gamedomain.LaunchableGame{}, err
}
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
_ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist)
return item, nil
}
func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) {
// 回调入口只知道 platform_code必须按 app_code 隔离查询,防止多租户平台编码冲突。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'),
callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
FROM game_platforms
WHERE app_code = ? AND platform_code = ?`,
appcode.Normalize(appCode), strings.TrimSpace(platformCode),
)
item, err := scanPlatform(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.Platform{}, xerr.New(xerr.NotFound, "game platform not found")
}
return gamedomain.Platform{}, err
}
return item, nil
}
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_launch_sessions (
app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID,
session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
)
return err
}
func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
FROM game_launch_sessions
WHERE app_code = ? AND launch_token_hash = ?`,
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
)
var item gamedomain.LaunchSession
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
}
return gamedomain.LaunchSession{}, err
}
return item, nil
}
func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_callback_logs (
app_code, callback_id, platform_code, operation, request_id, provider_request_id,
signature_valid, request_hash, response_code, response_body_hash, status, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(log.AppCode), log.CallbackID, log.PlatformCode, log.Operation, log.RequestID, log.ProviderRequestID,
log.SignatureValid, log.RequestHash, log.ResponseCode, log.ResponseBodyHash, log.Status, log.CreatedAtMS,
)
return err
}
func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return gamedomain.GameOrder{}, false, err
}
defer func() { _ = tx.Rollback() }()
existing, exists, err := queryOrderForUpdate(ctx, tx, order.AppCode, order.PlatformCode, order.ProviderOrderID)
if err != nil {
return gamedomain.GameOrder{}, false, err
}
if exists {
if existing.RequestHash != order.RequestHash {
_, _ = tx.ExecContext(ctx,
`UPDATE game_orders SET status = ?, failure_code = ?, failure_message = ?, updated_at_ms = ?
WHERE app_code = ? AND order_id = ?`,
gamedomain.OrderStatusConflict, string(xerr.IdempotencyConflict), "provider order payload conflict",
time.Now().UnixMilli(), appcode.Normalize(order.AppCode), existing.OrderID,
)
_ = tx.Commit()
return gamedomain.GameOrder{}, true, xerr.New(xerr.IdempotencyConflict, "provider order payload conflict")
}
if err := tx.Commit(); err != nil {
return gamedomain.GameOrder{}, false, err
}
return existing, true, nil
}
nowMs := time.Now().UnixMilli()
order.AppCode = appcode.Normalize(order.AppCode)
order.CreatedAtMS = nowMs
order.UpdatedAtMS = nowMs
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_orders (
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
order.UserID, order.RoomID, order.RegionID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
); err != nil {
return gamedomain.GameOrder{}, false, err
}
if err := tx.Commit(); err != nil {
return gamedomain.GameOrder{}, false, err
}
return order, false, nil
}
func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
FOR UPDATE`,
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
)
var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil
}
return gamedomain.GameOrder{}, false, err
}
return order, true, nil
}
func (r *Repository) MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
order, exists, err := queryOrderByIDForUpdate(ctx, tx, appCode, orderID)
if err != nil {
return err
}
if !exists {
return xerr.New(xerr.NotFound, "game order not found")
}
_, err = tx.ExecContext(ctx,
`UPDATE game_orders
SET status = ?, wallet_transaction_id = ?, wallet_balance_after = ?, failure_code = '', failure_message = '', updated_at_ms = ?
WHERE app_code = ? AND order_id = ?`,
gamedomain.OrderStatusSucceeded, walletTransactionID, balanceAfter, nowMs, appcode.Normalize(appCode), orderID,
)
if err != nil {
return err
}
if strings.EqualFold(order.OpType, "debit") && order.CoinAmount > 0 {
if err := insertGameLevelEventOutbox(ctx, tx, order, walletTransactionID, nowMs); err != nil {
return err
}
}
if err := insertGameStatisticsOutbox(ctx, tx, order, walletTransactionID, balanceAfter, nowMs); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE game_orders SET status = ?, failure_code = ?, failure_message = ?, updated_at_ms = ?
WHERE app_code = ? AND order_id = ?`,
status, code, truncate(message, 256), nowMs, appcode.Normalize(appCode), orderID,
)
return err
}
func (r *Repository) CreateRepairOrder(ctx context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
nowMs := time.Now().UnixMilli()
order.AppCode = appcode.Normalize(order.AppCode)
if order.CreatedAtMS == 0 {
order.CreatedAtMS = nowMs
}
order.UpdatedAtMS = nowMs
if strings.TrimSpace(order.Status) == "" {
order.Status = gamedomain.RepairStatusLogged
}
result, err := r.db.ExecContext(ctx,
`INSERT IGNORE INTO game_repair_orders (
app_code, repair_id, platform_code, provider_order_id, reason, status, operator_admin_id,
attempts, next_run_at_ms, last_error_code, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.RepairID, order.PlatformCode, order.ProviderOrderID, order.Reason, order.Status,
order.OperatorAdminID, order.Attempts, order.NextRunAtMS, order.LastErrorCode, order.CreatedAtMS, order.UpdatedAtMS,
)
if err != nil {
return gamedomain.RepairOrder{}, false, err
}
affected, _ := result.RowsAffected()
if affected == 0 {
existing, err := r.getRepairOrderByProvider(ctx, order.AppCode, order.PlatformCode, order.ProviderOrderID)
return existing, true, err
}
return order, false, nil
}
func (r *Repository) getRepairOrderByProvider(ctx context.Context, appCode string, platformCode string, providerOrderID string) (gamedomain.RepairOrder, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, repair_id, platform_code, provider_order_id, reason, status, operator_admin_id,
attempts, next_run_at_ms, last_error_code, created_at_ms, updated_at_ms
FROM game_repair_orders
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
)
var item gamedomain.RepairOrder
if err := row.Scan(&item.AppCode, &item.RepairID, &item.PlatformCode, &item.ProviderOrderID, &item.Reason, &item.Status, &item.OperatorAdminID, &item.Attempts, &item.NextRunAtMS, &item.LastErrorCode, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return gamedomain.RepairOrder{}, err
}
return item, nil
}
func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders
WHERE app_code = ? AND order_id = ?
FOR UPDATE`,
appcode.Normalize(appCode), strings.TrimSpace(orderID),
)
var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil
}
return gamedomain.GameOrder{}, false, err
}
return order, true, nil
}
func insertGameLevelEventOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, nowMs int64) error {
eventID := "game_level:" + order.OrderID
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO game_level_event_outbox (
app_code, event_id, order_id, user_id, game_id, provider_order_id, provider_round_id,
coin_amount, wallet_transaction_id, payload_json, status, next_retry_at_ms,
occurred_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT(
'platform_code', ?,
'game_id', ?,
'provider_game_id', ?,
'provider_order_id', ?,
'provider_round_id', ?,
'room_id', ?,
'wallet_transaction_id', ?
), 'pending', 0, ?, ?, ?)`,
order.AppCode, eventID, order.OrderID, order.UserID, order.GameID, order.ProviderOrderID, order.ProviderRoundID,
order.CoinAmount, walletTransactionID,
order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID, order.RoomID, walletTransactionID,
nowMs, nowMs, nowMs,
)
return err
}
func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, balanceAfter int64, nowMs int64) error {
eventID := "GameOrderSettled:" + order.OrderID
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO game_outbox (
app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, payload_json, status, created_at_ms, updated_at_ms
) VALUES (?, ?, 'GameOrderSettled', ?, ?, ?, ?, ?, ?, JSON_OBJECT(
'order_id', ?,
'platform_code', ?,
'game_id', ?,
'provider_game_id', ?,
'provider_order_id', ?,
'provider_round_id', ?,
'user_id', ?,
'room_id', ?,
'visible_region_id', ?,
'region_id', ?,
'op_type', ?,
'coin_amount', ?,
'wallet_transaction_id', ?,
'wallet_balance_after', ?
), 'pending', ?, ?)`,
order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
order.UserID, order.RoomID, order.RegionID, order.RegionID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
nowMs, nowMs,
)
return err
}
// GameOutboxRecord is a durable game-service fact waiting for MQ fanout.
type GameOutboxRecord struct {
AppCode string
EventID string
EventType string
OrderID string
UserID int64
PlatformCode string
GameID string
OpType string
CoinAmount int64
PayloadJSON string
CreatedAtMS int64
RetryCount int
LastError string
WorkerID string
LockUntilMS int64
}
// ClaimPendingGameOutbox locks pending game facts so one worker publishes them.
func (r *Repository) ClaimPendingGameOutbox(ctx context.Context, workerID string, batchSize int) ([]GameOutboxRecord, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if batchSize <= 0 {
batchSize = 100
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
workerID = "game-outbox-worker"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
nowMS := time.Now().UTC().UnixMilli()
lockUntilMS := nowMS + (30 * time.Second).Milliseconds()
appCode := appcode.FromContext(ctx)
records := make([]GameOutboxRecord, 0, batchSize)
claimBranches := []struct {
query string
args []any
}{
{
query: `
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, CAST(payload_json AS CHAR), created_at_ms
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
WHERE app_code = ? AND status = 'pending' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, CAST(payload_json AS CHAR), created_at_ms
FROM game_outbox FORCE INDEX (idx_game_outbox_status_created)
WHERE app_code = ? AND status = 'retryable' AND next_retry_at_ms <= ? AND lock_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
{
query: `
SELECT app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
coin_amount, CAST(payload_json AS CHAR), created_at_ms
FROM game_outbox FORCE INDEX (idx_game_outbox_lock)
WHERE app_code = ? AND status = 'running' AND lock_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED
`,
args: []any{appCode, nowMS},
},
}
for _, branch := range claimBranches {
remaining := batchSize - len(records)
if remaining <= 0 {
break
}
args := append(append([]any{}, branch.args...), remaining)
branchRecords, err := queryGameOutboxRecords(ctx, tx, branch.query, args...)
if err != nil {
return nil, err
}
records = append(records, branchRecords...)
}
for _, record := range records {
if _, err := tx.ExecContext(ctx, `
UPDATE game_outbox
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, workerID, lockUntilMS, nowMS, appcode.Normalize(record.AppCode), record.EventID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
for index := range records {
records[index].WorkerID = workerID
records[index].LockUntilMS = lockUntilMS
}
return records, nil
}
func queryGameOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]GameOutboxRecord, error) {
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]GameOutboxRecord, 0)
for rows.Next() {
var record GameOutboxRecord
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.OrderID, &record.UserID, &record.PlatformCode, &record.GameID, &record.OpType, &record.CoinAmount, &record.PayloadJSON, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
func (r *Repository) MarkGameOutboxDelivered(ctx context.Context, appCode string, eventID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE game_outbox
SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
WHERE app_code = ? AND event_id = ?
`, time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
return err
}
func (r *Repository) MarkGameOutboxRetryable(ctx context.Context, appCode string, eventID string, lastErr string, nextRetryAtMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE game_outbox
SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status = 'running'
`, nextRetryAtMS, truncate(lastErr, 512), time.Now().UTC().UnixMilli(), appcode.Normalize(appCode), eventID)
return err
}
func (r *Repository) ClaimPendingLevelEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]gamedomain.LevelEventOutbox, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
appCode := appcode.FromContext(ctx)
events, err := r.queryPendingLevelEvents(ctx, tx, `
WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ?
AND locked_until_ms = 0
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
appCode, nowMS, batchSize)
if err != nil {
return nil, err
}
if remaining := batchSize - len(events); remaining > 0 {
retryEvents, err := r.queryPendingLevelEvents(ctx, tx, `
WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ?
AND locked_until_ms > 0 AND locked_until_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
appCode, nowMS, nowMS, remaining)
if err != nil {
return nil, err
}
events = append(events, retryEvents...)
}
lockUntil := nowMS + lockTTL.Milliseconds()
for _, event := range events {
if _, err := tx.ExecContext(ctx, `
UPDATE game_level_event_outbox
SET status = 'running', attempt_count = attempt_count + 1, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
workerID, lockUntil, nowMS, appcode.FromContext(ctx), event.EventID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return events, nil
}
func (r *Repository) queryPendingLevelEvents(ctx context.Context, tx *sql.Tx, whereSQL string, args ...any) ([]gamedomain.LevelEventOutbox, error) {
rows, err := tx.QueryContext(ctx, levelEventOutboxSelectSQL()+whereSQL, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLevelEventOutboxRows(rows)
}
func (r *Repository) MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE game_level_event_outbox
SET status = 'delivered', locked_by = '', locked_until_ms = 0, failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
nowMS, appcode.FromContext(ctx), eventID,
)
return err
}
func (r *Repository) MarkLevelEventFailed(ctx context.Context, eventID string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
UPDATE game_level_event_outbox
SET status = 'failed', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0,
failure_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ? AND status <> 'delivered'`,
nextRetryAtMS, truncate(failureReason, 255), nowMS, appcode.FromContext(ctx), eventID,
)
return err
}
func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
query := `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'),
callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'),
COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms
FROM game_platforms WHERE app_code = ?`
args := []any{appcode.Normalize(appCode)}
if strings.TrimSpace(status) != "" {
query += " AND status = ?"
args = append(args, strings.TrimSpace(status))
}
query += " ORDER BY sort_order ASC, platform_code ASC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []gamedomain.Platform
for rows.Next() {
item, err := scanPlatform(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error) {
nowMs := time.Now().UnixMilli()
platform.AppCode = appcode.Normalize(platform.AppCode)
if platform.CreatedAtMS == 0 {
platform.CreatedAtMS = nowMs
}
platform.UpdatedAtMS = nowMs
whitelistJSON, _ := json.Marshal(platform.CallbackIPWhitelist)
adapterConfig := strings.TrimSpace(platform.AdapterConfigJSON)
if adapterConfig == "" {
adapterConfig = "{}"
}
// callback_secret_ciphertext 空值表示“保持旧密钥”,这样后台编辑 URL/白名单时不会误清密钥。
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (
app_code, platform_code, platform_name, status, api_base_url, adapter_type,
callback_secret_ciphertext, callback_ip_whitelist, adapter_config,
sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name), status = VALUES(status),
api_base_url = VALUES(api_base_url), adapter_type = VALUES(adapter_type),
callback_secret_ciphertext = IF(VALUES(callback_secret_ciphertext) = '', callback_secret_ciphertext, VALUES(callback_secret_ciphertext)),
callback_ip_whitelist = VALUES(callback_ip_whitelist), adapter_config = VALUES(adapter_config),
sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.AdapterType,
platform.CallbackSecretCiphertext, string(whitelistJSON), adapterConfig, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS,
)
if err != nil {
return gamedomain.Platform{}, err
}
return r.GetPlatform(ctx, platform.AppCode, platform.PlatformCode)
}
func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
sqlText := `SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
FROM game_catalog WHERE app_code = ?`
args := []any{appcode.Normalize(query.AppCode)}
if strings.TrimSpace(query.PlatformCode) != "" {
sqlText += " AND platform_code = ?"
args = append(args, strings.TrimSpace(query.PlatformCode))
}
if strings.TrimSpace(query.Status) != "" {
sqlText += " AND status = ?"
args = append(args, strings.TrimSpace(query.Status))
}
sqlText += " ORDER BY sort_order ASC, game_id ASC"
if query.PageSize > 0 {
sqlText += " LIMIT ?"
args = append(args, query.PageSize)
}
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
var items []gamedomain.CatalogItem
for rows.Next() {
item, err := scanCatalog(rows)
if err != nil {
return nil, "", err
}
items = append(items, item)
}
return items, "", rows.Err()
}
func (r *Repository) UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
nowMs := time.Now().UnixMilli()
item.AppCode = appcode.Normalize(item.AppCode)
if item.CreatedAtMS == 0 {
item.CreatedAtMS = nowMs
}
item.UpdatedAtMS = nowMs
tagsJSON, _ := json.Marshal(item.Tags)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return gamedomain.CatalogItem{}, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE platform_code = VALUES(platform_code), provider_game_id = VALUES(provider_game_id),
game_name = VALUES(game_name), category = VALUES(category), icon_url = VALUES(icon_url), cover_url = VALUES(cover_url),
launch_mode = VALUES(launch_mode), orientation = VALUES(orientation), min_coin = VALUES(min_coin),
status = VALUES(status), sort_order = VALUES(sort_order), tags = VALUES(tags), updated_at_ms = VALUES(updated_at_ms)`,
item.AppCode, item.GameID, item.PlatformCode, item.ProviderGameID, item.GameName, item.Category, item.IconURL, item.CoverURL,
item.LaunchMode, item.Orientation, item.MinCoin, item.Status, item.SortOrder, string(tagsJSON), item.CreatedAtMS, item.UpdatedAtMS,
); err != nil {
return gamedomain.CatalogItem{}, err
}
// App 的 /games 当前按 scene=voice_room 读取展示规则;导入目录时同步补默认规则,避免后台同步成功但 App 看不到。
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_display_rules (
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, '', '', 1, 1, ?, ?, ?)
ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled),
sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
item.AppCode, defaultVoiceRoomRuleID(item.GameID), item.GameID, gamedomain.SceneVoiceRoom, item.SortOrder, item.CreatedAtMS, item.UpdatedAtMS,
); err != nil {
return gamedomain.CatalogItem{}, err
}
return item, tx.Commit()
}
func defaultVoiceRoomRuleID(gameID string) string {
ruleID := strings.TrimSpace(gameID) + "_voice_default"
if len(ruleID) <= 96 {
return ruleID
}
sum := sha256.Sum256([]byte(gameID))
return "voice_default_" + hex.EncodeToString(sum[:16])
}
func (r *Repository) SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error) {
if _, err := r.db.ExecContext(ctx,
`UPDATE game_catalog SET status = ?, updated_at_ms = ? WHERE app_code = ? AND game_id = ?`,
status, nowMs, appcode.Normalize(appCode), strings.TrimSpace(gameID),
); err != nil {
return gamedomain.CatalogItem{}, err
}
return r.getCatalog(ctx, appCode, gameID)
}
func (r *Repository) DeleteCatalog(ctx context.Context, appCode string, gameID string) error {
app := appcode.Normalize(appCode)
id := strings.TrimSpace(gameID)
if id == "" {
return xerr.New(xerr.InvalidArgument, "game_id is required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `DELETE FROM game_catalog WHERE app_code = ? AND game_id = ?`, app, id)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return xerr.New(xerr.NotFound, "game not found")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM game_display_rules WHERE app_code = ? AND game_id = ?`, app, id); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) getCatalog(ctx context.Context, appCode string, gameID string) (gamedomain.CatalogItem, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
FROM game_catalog WHERE app_code = ? AND game_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
item, err := scanCatalog(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.CatalogItem{}, xerr.New(xerr.NotFound, "game not found")
}
return gamedomain.CatalogItem{}, err
}
return item, nil
}
type catalogScanner interface {
Scan(dest ...any) error
}
func scanLevelEventOutboxRows(rows *sql.Rows) ([]gamedomain.LevelEventOutbox, error) {
items := make([]gamedomain.LevelEventOutbox, 0)
for rows.Next() {
item, err := scanLevelEventOutbox(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func scanLevelEventOutbox(scanner catalogScanner) (gamedomain.LevelEventOutbox, error) {
var item gamedomain.LevelEventOutbox
err := scanner.Scan(
&item.AppCode, &item.EventID, &item.OrderID, &item.UserID, &item.GameID,
&item.ProviderOrderID, &item.ProviderRoundID, &item.CoinAmount,
&item.WalletTransactionID, &item.PayloadJSON, &item.Status,
&item.AttemptCount, &item.FailureReason, &item.OccurredAtMS,
&item.CreatedAtMS, &item.UpdatedAtMS,
)
return item, err
}
func scanPlatform(scanner catalogScanner) (gamedomain.Platform, error) {
var item gamedomain.Platform
var whitelistJSON string
if err := scanner.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return gamedomain.Platform{}, err
}
// 只把“是否已配置”暴露给上层,密钥明文不再通过 DTO/Proto 回给后台。
item.CallbackSecretConfigured = strings.TrimSpace(item.CallbackSecretCiphertext) != ""
_ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist)
return item, nil
}
func levelEventOutboxSelectSQL() string {
return `SELECT app_code, event_id, order_id, user_id, game_id, provider_order_id,
provider_round_id, coin_amount, wallet_transaction_id,
COALESCE(CAST(payload_json AS CHAR), '{}'), status, attempt_count,
failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim) `
}
func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
var item gamedomain.CatalogItem
var tagsJSON string
if err := scanner.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return gamedomain.CatalogItem{}, err
}
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
return item, nil
}
func truncate(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max]
}
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}