1524 lines
65 KiB
Go
1524 lines
65 KiB
Go
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[:])
|
||
}
|