2026-06-11 18:14:04 +08:00

1524 lines
65 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 '',
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',
safe_height INT NOT NULL DEFAULT 0,
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_dice_matches (
app_code VARCHAR(32) NOT NULL,
match_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
min_players INT NOT NULL,
max_players INT NOT NULL,
current_players INT NOT NULL DEFAULT 0,
stake_coin BIGINT NOT NULL,
round_no INT NOT NULL DEFAULT 1,
status VARCHAR(32) NOT NULL,
result VARCHAR(64) NOT NULL DEFAULT '',
join_deadline_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
settled_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(app_code, match_id),
KEY idx_game_dice_room_active(app_code, room_id, status, created_at_ms),
KEY idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_dice_participants (
app_code VARCHAR(32) NOT NULL,
match_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
seat_no INT NOT NULL,
status VARCHAR(32) NOT NULL,
stake_coin BIGINT NOT NULL,
dice_points_json JSON NULL,
rps_gesture VARCHAR(32) NOT NULL DEFAULT '',
result VARCHAR(32) NOT NULL DEFAULT '',
payout_coin BIGINT NOT NULL DEFAULT 0,
debit_order_id VARCHAR(96) NOT NULL DEFAULT '',
payout_order_id VARCHAR(96) NOT NULL DEFAULT '',
refund_order_id VARCHAR(96) NOT NULL DEFAULT '',
balance_after BIGINT NOT NULL DEFAULT 0,
joined_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, match_id, user_id),
UNIQUE KEY uk_game_dice_seat(app_code, match_id, seat_no),
KEY idx_game_dice_participant_user(app_code, user_id, updated_at_ms),
KEY idx_game_dice_participant_status(app_code, match_id, status, seat_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_explore_winners (
app_code VARCHAR(32) NOT NULL,
win_id VARCHAR(96) NOT NULL,
game_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
avatar_url VARCHAR(512) NOT NULL DEFAULT '',
coin_amount BIGINT NOT NULL,
won_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, win_id),
KEY idx_game_explore_winners_time(app_code, won_at_ms, win_id),
KEY idx_game_explore_winners_game(app_code, game_code, won_at_ms)
) 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_claim_order(app_code, status, locked_until_ms, created_at_ms, event_id, next_retry_at_ms),
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 '' AFTER api_base_url"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_catalog", "safe_height", "safe_height INT NOT NULL DEFAULT 0 AFTER orientation"); 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_dice_participants", "rps_gesture", "rps_gesture VARCHAR(32) NOT NULL DEFAULT '' AFTER dice_points_json"); 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_claim", []string{"app_code", "status", "locked_until_ms", "next_retry_at_ms", "created_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_claim(app_code, status, locked_until_ms, next_retry_at_ms, created_at_ms, event_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_claim_order", []string{"app_code", "status", "locked_until_ms", "created_at_ms", "event_id", "next_retry_at_ms"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_claim_order(app_code, status, locked_until_ms, created_at_ms, event_id, next_retry_at_ms)"); 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
}
if err := r.ensureIndexDefinition(ctx, "game_dice_matches", "idx_game_dice_room_active", []string{"app_code", "room_id", "status", "created_at_ms"}, "ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_room_active(app_code, room_id, status, created_at_ms)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_matches", "idx_game_dice_status_time", []string{"app_code", "status", "updated_at_ms", "match_id"}, "ALTER TABLE game_dice_matches ADD INDEX idx_game_dice_status_time(app_code, status, updated_at_ms, match_id)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "uk_game_dice_seat", []string{"app_code", "match_id", "seat_no"}, "ALTER TABLE game_dice_participants ADD UNIQUE INDEX uk_game_dice_seat(app_code, match_id, seat_no)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_user", []string{"app_code", "user_id", "updated_at_ms"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_user(app_code, user_id, updated_at_ms)"); err != nil {
return err
}
if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_status", []string{"app_code", "match_id", "status", "seat_no"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_status(app_code, match_id, status, seat_no)"); 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.safe_height, 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.safe_height, 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.SafeHeight, &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) ListRecentGames(ctx context.Context, query gameservice.ListRecentGamesQuery) ([]gamedomain.AppGame, error) {
pageSize := int(query.PageSize)
if pageSize <= 0 {
pageSize = 4
}
// My Games 先按当前用户的启动会话聚合出每个游戏的最后一次启动时间,
// 再套当前 catalog、platform 和 display_rules保证最近玩过但已下架或当前区域不可见的游戏不会返回。
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.safe_height, 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,
recent.last_played_at_ms
FROM (
SELECT game_id, MAX(created_at_ms) AS last_played_at_ms
FROM game_launch_sessions
WHERE app_code = ?
AND user_id = ?
AND scene = ?
GROUP BY game_id
) recent
JOIN game_catalog c ON c.app_code = ? AND c.game_id = recent.game_id
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.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.safe_height, c.min_coin, c.status, p.status,
c.sort_order, p.sort_order, recent.last_played_at_ms
ORDER BY recent.last_played_at_ms DESC, display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC
LIMIT ?`,
appcode.Normalize(query.AppCode), query.UserID, query.Scene,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, pageSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gamedomain.AppGame{}
for rows.Next() {
var item gamedomain.AppGame
var gameStatus, platformStatus string
var displaySort int32
var lastPlayedAtMS int64
if err := rows.Scan(&item.GameID, &item.PlatformCode, &item.Name, &item.IconURL, &item.CoverURL, &item.Category, &item.LaunchMode, &item.Orientation, &item.SafeHeight, &item.MinCoin, &gameStatus, &platformStatus, &displaySort, &lastPlayedAtMS); err != nil {
return nil, err
}
item.NameKey = "game." + item.GameID + ".name"
item.SortOrder = displaySort
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.safe_height, 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, ''),
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.SafeHeight, &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, ''),
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) GetLaunchSessionByTokenScope(ctx context.Context, appCode string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error) {
// 同一个 App access token 会被多个厂商和多个游戏复用,回调校验必须把 token、平台和可选游戏 ID 一起收窄。
// 这里按 created_at_ms 倒序取最新一条,避免同用户重复打开同一游戏时拿到旧房间或旧 session。
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 = ?
AND platform_code = ?
AND (? = '' OR provider_game_id = ?)
ORDER BY created_at_ms DESC
LIMIT 1`,
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), strings.TrimSpace(platformCode), strings.TrimSpace(providerGameID), strings.TrimSpace(providerGameID),
)
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")
}
if batchSize <= 0 {
return nil, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
appCode := appcode.FromContext(ctx)
// 先用轻量主键查询完成行锁 claim避免 payload_json 和完整业务列参与排序。
keys, err := r.queryUnlockedLevelEventClaimKeys(ctx, tx, appCode, nowMS, batchSize)
if err != nil {
return nil, err
}
// 普通未锁事件优先处理;不足一批时再接管锁过期事件,保持原有补偿顺序。
if remaining := batchSize - len(keys); remaining > 0 {
retryKeys, err := r.queryExpiredLevelEventClaimKeys(ctx, tx, appCode, nowMS, remaining)
if err != nil {
return nil, err
}
keys = append(keys, retryKeys...)
}
// 状态更新前按主键回读完整事件,让上层仍拿到 pending/failed 的原始状态和负载。
events, err := r.fetchLevelEventsByClaimKeys(ctx, tx, appCode, keys)
if err != nil {
return nil, err
}
lockUntil := nowMS + lockTTL.Milliseconds()
// 只有被本事务锁住且准备返回的事件会切到 running额外扫描到的候选行随提交释放锁。
for _, key := range keys {
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, key.EventID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return events, nil
}
type levelEventClaimKey struct {
EventID string
CreatedAtMS int64
}
func (r *Repository) queryUnlockedLevelEventClaimKeys(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64, batchSize int) ([]levelEventClaimKey, error) {
// status 拆成两个等值查询,避免 MySQL 把 status IN 展开成多段 range 后再 filesort。
return queryLevelEventClaimKeysByStatus(batchSize, func(status string) ([]levelEventClaimKey, error) {
rows, err := tx.QueryContext(ctx, `
SELECT event_id, created_at_ms
FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim_order)
WHERE app_code = ? AND status = ? AND locked_until_ms = 0 AND next_retry_at_ms <= ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ? FOR UPDATE SKIP LOCKED`,
appCode, status, nowMS, batchSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLevelEventClaimKeys(rows)
})
}
func (r *Repository) queryExpiredLevelEventClaimKeys(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64, batchSize int) ([]levelEventClaimKey, error) {
// 过期锁接管仍使用 retry/lock 前缀缩小范围,但只锁主键,避免大 JSON 字段进入排序和慢日志。
return queryLevelEventClaimKeysByStatus(batchSize, func(status string) ([]levelEventClaimKey, error) {
rows, err := tx.QueryContext(ctx, `
SELECT event_id, created_at_ms
FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim)
WHERE app_code = ? AND status = ? 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, status, nowMS, nowMS, batchSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLevelEventClaimKeys(rows)
})
}
func queryLevelEventClaimKeysByStatus(batchSize int, query func(status string) ([]levelEventClaimKey, error)) ([]levelEventClaimKey, error) {
statuses := []string{"pending", "failed"}
groups := make([][]levelEventClaimKey, 0, len(statuses))
for _, status := range statuses {
keys, err := query(status)
if err != nil {
return nil, err
}
groups = append(groups, keys)
}
return mergeLevelEventClaimKeys(batchSize, groups...), nil
}
func scanLevelEventClaimKeys(rows *sql.Rows) ([]levelEventClaimKey, error) {
keys := make([]levelEventClaimKey, 0)
for rows.Next() {
var key levelEventClaimKey
if err := rows.Scan(&key.EventID, &key.CreatedAtMS); err != nil {
return nil, err
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func mergeLevelEventClaimKeys(limit int, groups ...[]levelEventClaimKey) []levelEventClaimKey {
if limit <= 0 {
return nil
}
offsets := make([]int, len(groups))
merged := make([]levelEventClaimKey, 0, limit)
// 每个 status 查询内部已经按 created_at/event_id 排序,这里做小规模归并来维持原 SQL 的跨状态顺序。
for len(merged) < limit {
bestGroup := -1
var best levelEventClaimKey
for groupIndex, group := range groups {
offset := offsets[groupIndex]
if offset >= len(group) {
continue
}
candidate := group[offset]
if bestGroup == -1 || levelEventClaimKeyLess(candidate, best) {
bestGroup = groupIndex
best = candidate
}
}
if bestGroup == -1 {
break
}
merged = append(merged, best)
offsets[bestGroup]++
}
return merged
}
func levelEventClaimKeyLess(left levelEventClaimKey, right levelEventClaimKey) bool {
if left.CreatedAtMS != right.CreatedAtMS {
return left.CreatedAtMS < right.CreatedAtMS
}
return left.EventID < right.EventID
}
func (r *Repository) fetchLevelEventsByClaimKeys(ctx context.Context, tx *sql.Tx, appCode string, keys []levelEventClaimKey) ([]gamedomain.LevelEventOutbox, error) {
if len(keys) == 0 {
return nil, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(keys)), ",")
args := make([]any, 0, len(keys)+1)
args = append(args, appCode)
for _, key := range keys {
args = append(args, key.EventID)
}
rows, err := tx.QueryContext(ctx, levelEventOutboxSelectSQL()+`WHERE app_code = ? AND event_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanLevelEventOutboxRows(rows)
if err != nil {
return nil, err
}
byID := make(map[string]gamedomain.LevelEventOutbox, len(items))
for _, item := range items {
byID[item.EventID] = item
}
ordered := make([]gamedomain.LevelEventOutbox, 0, len(keys))
for _, key := range keys {
item, ok := byID[key.EventID]
if !ok {
return nil, errors.New("claimed level event disappeared before fetch")
}
ordered = append(ordered, item)
}
return ordered, nil
}
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, ''),
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, safe_height, 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, safe_height, 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), safe_height = VALUES(safe_height), 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.SafeHeight, 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, safe_height, 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.SafeHeight, &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[:])
}