2026-05-12 19:34:47 +08:00

547 lines
24 KiB
Go

package mysql
import (
"context"
"database/sql"
"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 '',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
callback_ip_whitelist JSON NULL,
adapter_config JSON NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, platform_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_catalog (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
game_name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
min_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
tags JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id),
UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_display_rules (
app_code VARCHAR(32) NOT NULL,
rule_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
scene VARCHAR(64) NOT NULL,
region_id BIGINT NOT NULL DEFAULT 0,
language VARCHAR(32) NOT NULL DEFAULT '',
platform VARCHAR(16) NOT NULL DEFAULT '',
visible TINYINT(1) NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, rule_id),
KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_launch_sessions (
app_code VARCHAR(32) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
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 '',
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
wallet_balance_after BIGINT NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
failure_code VARCHAR(64) NOT NULL DEFAULT '',
failure_message VARCHAR(256) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, order_id),
UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL,
callback_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
request_id VARCHAR(96) NOT NULL DEFAULT '',
provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
response_code VARCHAR(64) NOT NULL DEFAULT '',
response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, callback_id),
KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_repair_orders (
app_code VARCHAR(32) NOT NULL,
repair_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
reason VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
last_error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, repair_id),
UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return r.seedDefaults(ctx)
}
func (r *Repository) seedDefaults(ctx context.Context) error {
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
VALUES ('lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0)
ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name)`); err != nil {
return err
}
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name)`); err != nil {
return err
}
_, err := r.db.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 (
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled)`)
return err
}
func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.min_coin, c.status, p.status,
MIN(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort
FROM game_catalog c
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id
WHERE c.app_code = ?
AND c.status IN ('active', 'maintenance')
AND p.status IN ('active', 'maintenance')
AND r.scene = ?
AND r.enabled = 1
AND r.visible = 1
AND (r.region_id = 0 OR r.region_id = ?)
AND (r.language = '' OR r.language = ?)
AND (r.platform = '' OR r.platform = ?)
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.min_coin, c.status, p.status, c.sort_order, p.sort_order
ORDER BY display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC`,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gamedomain.AppGame{}
for rows.Next() {
var item gamedomain.AppGame
var gameStatus, platformStatus string
if err := rows.Scan(&item.GameID, &item.PlatformCode, &item.Name, &item.IconURL, &item.CoverURL, &item.Category, &item.LaunchMode, &item.Orientation, &item.MinCoin, &gameStatus, &platformStatus, &item.SortOrder); err != nil {
return nil, err
}
item.NameKey = "game." + item.GameID + ".name"
item.Enabled = gameStatus == gamedomain.StatusActive && platformStatus == gamedomain.StatusActive
item.Maintenance = gameStatus == gamedomain.StatusMaintenance || platformStatus == gamedomain.StatusMaintenance
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) {
row := r.db.QueryRowContext(ctx,
`SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category,
c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.min_coin, c.status,
c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms,
p.platform_name, p.status, p.api_base_url
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
if err := row.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL); 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)
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, 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.Scene,
session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
)
return err
}
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, 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.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, 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.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 {
_, err := r.db.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,
)
return err
}
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) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
query := `SELECT app_code, platform_code, platform_name, status, api_base_url, 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() {
var item gamedomain.Platform
if err := rows.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); 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
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, 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), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS,
)
return platform, err
}
func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
sqlText := `SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
FROM game_catalog WHERE app_code = ?`
args := []any{appcode.Normalize(query.AppCode)}
if strings.TrimSpace(query.PlatformCode) != "" {
sqlText += " AND platform_code = ?"
args = append(args, strings.TrimSpace(query.PlatformCode))
}
if strings.TrimSpace(query.Status) != "" {
sqlText += " AND status = ?"
args = append(args, strings.TrimSpace(query.Status))
}
sqlText += " ORDER BY sort_order ASC, game_id ASC"
if query.PageSize > 0 {
sqlText += " LIMIT ?"
args = append(args, query.PageSize)
}
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
var items []gamedomain.CatalogItem
for rows.Next() {
item, err := scanCatalog(rows)
if err != nil {
return nil, "", err
}
items = append(items, item)
}
return items, "", rows.Err()
}
func (r *Repository) UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
nowMs := time.Now().UnixMilli()
item.AppCode = appcode.Normalize(item.AppCode)
if item.CreatedAtMS == 0 {
item.CreatedAtMS = nowMs
}
item.UpdatedAtMS = nowMs
tagsJSON, _ := json.Marshal(item.Tags)
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE platform_code = VALUES(platform_code), provider_game_id = VALUES(provider_game_id),
game_name = VALUES(game_name), category = VALUES(category), icon_url = VALUES(icon_url), cover_url = VALUES(cover_url),
launch_mode = VALUES(launch_mode), orientation = VALUES(orientation), min_coin = VALUES(min_coin),
status = VALUES(status), sort_order = VALUES(sort_order), tags = VALUES(tags), updated_at_ms = VALUES(updated_at_ms)`,
item.AppCode, item.GameID, item.PlatformCode, item.ProviderGameID, item.GameName, item.Category, item.IconURL, item.CoverURL,
item.LaunchMode, item.Orientation, item.MinCoin, item.Status, item.SortOrder, string(tagsJSON), item.CreatedAtMS, item.UpdatedAtMS,
)
return item, err
}
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) getCatalog(ctx context.Context, appCode string, gameID string) (gamedomain.CatalogItem, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
FROM game_catalog WHERE app_code = ? AND game_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
item, err := scanCatalog(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.CatalogItem{}, xerr.New(xerr.NotFound, "game not found")
}
return gamedomain.CatalogItem{}, err
}
return item, nil
}
type catalogScanner interface {
Scan(dest ...any) error
}
func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
var item gamedomain.CatalogItem
var tagsJSON string
if err := scanner.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return gamedomain.CatalogItem{}, err
}
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
return item, nil
}
func truncate(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max]
}