2026-06-12 19:40:21 +08:00

722 lines
25 KiB
Go

package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
robotdomain "hyapp/services/robot-service/internal/domain/robot"
)
const (
tableGameRobots = "robot_game_robots"
tableRoomRobots = "robot_room_robots"
tableServiceMigrations = "robot_service_migrations"
migrationGameRobotsV1 = "game_robots_from_game_self_game_robots_v1"
legacyGameRobotTable = "game_self_game_robots"
defaultLegacyGameID = robotdomain.DefaultGameID
)
var mysqlIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// Repository 是 robot-service 的持久化入口;新逻辑只读写 hyapp_robot 下的 robot_* 表,旧 game_self_game_robots 只作为一次性迁移来源。
type Repository struct {
db *sql.DB
}
func Open(ctx context.Context, dsn string) (*Repository, error) {
db, err := sql.Open("mysql", strings.TrimSpace(dsn))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
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, "robot repository is not configured")
}
return r.db.PingContext(ctx)
}
func (r *Repository) Migrate(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS robot_game_robots (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
last_used_at_ms BIGINT NOT NULL DEFAULT 0,
used_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(app_code, game_id, user_id),
KEY idx_robot_game_status(app_code, game_id, status, updated_at_ms, user_id),
KEY idx_robot_game_random_pool(app_code, status, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS robot_room_robots (
app_code VARCHAR(32) NOT NULL,
room_scene VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_by_admin_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
last_used_at_ms BIGINT NOT NULL DEFAULT 0,
used_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY(app_code, room_scene, user_id),
KEY idx_robot_room_status(app_code, room_scene, status, updated_at_ms, user_id),
KEY idx_robot_room_random_pool(app_code, status, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS robot_service_migrations (
migration_key VARCHAR(128) NOT NULL,
source_database VARCHAR(64) NOT NULL DEFAULT '',
source_table VARCHAR(64) NOT NULL DEFAULT '',
migrated_rows BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(migration_key)
) 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 nil
}
type MigrationResult struct {
Applied bool
AlreadyCompleted bool
SourceFound bool
LegacyDatabase string
MigratedRows int64
}
func (r *Repository) MigrateLegacyGameRobots(ctx context.Context, legacyDatabase string) (MigrationResult, error) {
legacyDatabase = strings.TrimSpace(legacyDatabase)
result := MigrationResult{LegacyDatabase: legacyDatabase}
if legacyDatabase == "" {
return result, nil
}
if !mysqlIdentifierPattern.MatchString(legacyDatabase) {
return result, xerr.New(xerr.InvalidArgument, "legacy game database name is invalid")
}
completed, err := r.migrationCompleted(ctx, migrationGameRobotsV1)
if err != nil {
return result, err
}
if completed {
result.AlreadyCompleted = true
return result, nil
}
sourceFound, err := r.tableExistsInSchema(ctx, legacyDatabase, legacyGameRobotTable)
if err != nil {
return result, err
}
result.SourceFound = sourceFound
if !sourceFound {
return result, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer func() { _ = tx.Rollback() }()
// 用迁移表行锁串行化首次复制,避免多实例启动时重复读旧表;复制只插入/补齐新表,绝不删除旧 game-service 数据。
var lockedKey string
lockErr := tx.QueryRowContext(ctx, `SELECT migration_key FROM robot_service_migrations WHERE migration_key = ? FOR UPDATE`, migrationGameRobotsV1).Scan(&lockedKey)
if lockErr != nil && !errors.Is(lockErr, sql.ErrNoRows) {
return result, lockErr
}
if lockedKey != "" {
if err := tx.Commit(); err != nil {
return result, err
}
result.AlreadyCompleted = true
return result, nil
}
sourceTable := quoteQualifiedIdentifier(legacyDatabase, legacyGameRobotTable)
var sourceRows int64
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM (SELECT app_code, user_id FROM %s GROUP BY app_code, user_id) migrated`, sourceTable)
if err := tx.QueryRowContext(ctx, countQuery).Scan(&sourceRows); err != nil {
return result, err
}
copyQuery := fmt.Sprintf(
`INSERT INTO robot_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
)
SELECT app_code, ? AS game_id, user_id,
IF(SUM(status = ?) > 0, ?, ?) AS status,
MIN(created_by_admin_id) AS created_by_admin_id,
MIN(created_at_ms) AS created_at_ms,
MAX(updated_at_ms) AS updated_at_ms,
MAX(last_used_at_ms) AS last_used_at_ms,
MAX(used_count) AS used_count
FROM %s
GROUP BY app_code, user_id
ON DUPLICATE KEY UPDATE
status = IF(VALUES(status) = ?, ?, robot_game_robots.status),
updated_at_ms = GREATEST(robot_game_robots.updated_at_ms, VALUES(updated_at_ms)),
last_used_at_ms = GREATEST(robot_game_robots.last_used_at_ms, VALUES(last_used_at_ms)),
used_count = GREATEST(robot_game_robots.used_count, VALUES(used_count))`,
sourceTable,
)
if _, err := tx.ExecContext(ctx, copyQuery,
defaultLegacyGameID,
robotdomain.StatusActive,
robotdomain.StatusActive,
robotdomain.StatusDisabled,
robotdomain.StatusActive,
robotdomain.StatusActive,
); err != nil {
return result, err
}
nowMS := time.Now().UnixMilli()
if _, err := tx.ExecContext(ctx,
`INSERT INTO robot_service_migrations (
migration_key, source_database, source_table, migrated_rows, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
migrationGameRobotsV1, legacyDatabase, legacyGameRobotTable, sourceRows, "completed", nowMS, nowMS,
); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
result.Applied = true
result.MigratedRows = sourceRows
return result, nil
}
func (r *Repository) PickGameRobot(ctx context.Context, appCode string, gameID string, excludeUserIDs []int64, nowMS int64) (robotdomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = robotdomain.NormalizeGameID(gameID)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return robotdomain.Robot{}, err
}
defer func() { _ = tx.Rollback() }()
var item robotdomain.Robot
err = tx.QueryRowContext(ctx,
gameRobotPickQuery(excludeUserIDs),
append([]any{
gameID, robotdomain.StatusActive, app, robotdomain.StatusActive,
}, int64SliceToAny(uniquePositiveInt64(excludeUserIDs))...)...,
).Scan(
&item.AppCode, &item.GameID, &item.UserID, &item.Status, &item.CreatedByAdminID,
&item.CreatedAtMS, &item.UpdatedAtMS, &item.LastUsedAtMS, &item.UsedCount,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "game robot not found")
}
return robotdomain.Robot{}, err
}
res, err := tx.ExecContext(ctx,
`UPDATE robot_game_robots
SET last_used_at_ms = ?, used_count = used_count + 1
WHERE app_code = ? AND user_id = ? AND status = ?`,
nowMS, app, item.UserID, robotdomain.StatusActive,
)
if err != nil {
return robotdomain.Robot{}, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "game robot not found")
}
if err := tx.Commit(); err != nil {
return robotdomain.Robot{}, err
}
item.Scope = robotdomain.ScopeGame
item.LastUsedAtMS = nowMS
item.UsedCount++
return item, nil
}
func gameRobotPickQuery(excludeUserIDs []int64) string {
query := `SELECT r.app_code, ? AS game_id, r.user_id, ? AS status,
r.created_by_admin_id, r.created_at_ms, r.updated_at_ms,
r.last_used_at_ms, r.used_count
FROM robot_game_robots r
WHERE r.app_code = ? AND r.status = ?`
if ids := uniquePositiveInt64(excludeUserIDs); len(ids) > 0 {
query += ` AND r.user_id NOT IN (` + strings.TrimRight(strings.Repeat("?,", len(ids)), ",") + `)`
}
query += ` ORDER BY RAND() LIMIT 1 FOR UPDATE SKIP LOCKED`
return query
}
func (r *Repository) ListGameRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]robotdomain.Robot, string, error) {
app := appcode.Normalize(appCode)
gameID = robotdomain.NormalizeGameID(gameID)
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
status = strings.TrimSpace(status)
cursorID := int64(0)
if strings.TrimSpace(cursor) != "" {
_, _ = fmt.Sscan(strings.TrimSpace(cursor), &cursorID)
}
args := []any{gameID, robotdomain.StatusActive, robotdomain.StatusActive, robotdomain.StatusDisabled, app}
query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
FROM (
SELECT app_code, ? AS game_id, user_id,
IF(SUM(status = ?) > 0, ?, ?) AS status,
MIN(created_by_admin_id) AS created_by_admin_id,
MIN(created_at_ms) AS created_at_ms,
MAX(updated_at_ms) AS updated_at_ms,
MAX(last_used_at_ms) AS last_used_at_ms,
MAX(used_count) AS used_count
FROM robot_game_robots
WHERE app_code = ?`
if cursorID > 0 {
query += ` AND user_id > ?`
args = append(args, cursorID)
}
query += ` GROUP BY app_code, user_id
) robots
WHERE (? = '' OR status = ?)
ORDER BY user_id ASC LIMIT ?`
args = append(args, status, status, int(pageSize)+1)
return scanRobotList(ctx, r.db, robotdomain.ScopeGame, query, args, pageSize)
}
func (r *Repository) RegisterGameRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMS int64) ([]robotdomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = robotdomain.DefaultGameID
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
for _, userID := range uniquePositiveInt64(userIDs) {
if _, err := tx.ExecContext(ctx,
`INSERT INTO robot_game_robots (
app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
last_used_at_ms, used_count
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)
ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`,
app, gameID, userID, robotdomain.StatusActive, adminID, nowMS, nowMS,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
items, _, err := r.ListGameRobots(ctx, app, "", "", 200, "")
return items, err
}
func (r *Repository) SetGameRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMS int64) (robotdomain.Robot, error) {
app := appcode.Normalize(appCode)
gameID = robotdomain.NormalizeGameID(gameID)
status = robotdomain.NormalizeStatus(status)
if status != robotdomain.StatusActive && status != robotdomain.StatusDisabled {
return robotdomain.Robot{}, xerr.New(xerr.InvalidArgument, "game robot status is invalid")
}
res, err := r.db.ExecContext(ctx,
`UPDATE robot_game_robots
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?`,
status, nowMS, app, userID,
)
if err != nil {
return robotdomain.Robot{}, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "game robot not found")
}
return r.getUniversalGameRobot(ctx, app, gameID, userID)
}
func (r *Repository) DeleteGameRobot(ctx context.Context, appCode string, userID int64) error {
app := appcode.Normalize(appCode)
res, err := r.db.ExecContext(ctx, `DELETE FROM robot_game_robots WHERE app_code = ? AND user_id = ?`, app, userID)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return xerr.New(xerr.NotFound, "game robot not found")
}
return nil
}
func (r *Repository) getUniversalGameRobot(ctx context.Context, appCode string, gameID string, userID int64) (robotdomain.Robot, error) {
var item robotdomain.Robot
err := r.db.QueryRowContext(ctx,
`SELECT app_code, ? AS game_id, user_id,
IF(SUM(status = ?) > 0, ?, ?) AS status,
MIN(created_by_admin_id), MIN(created_at_ms), MAX(updated_at_ms),
MAX(last_used_at_ms), MAX(used_count)
FROM robot_game_robots
WHERE app_code = ? AND user_id = ?
GROUP BY app_code, user_id`,
gameID, robotdomain.StatusActive, robotdomain.StatusActive, robotdomain.StatusDisabled, appCode, userID,
).Scan(
&item.AppCode, &item.GameID, &item.UserID, &item.Status, &item.CreatedByAdminID,
&item.CreatedAtMS, &item.UpdatedAtMS, &item.LastUsedAtMS, &item.UsedCount,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "game robot not found")
}
return robotdomain.Robot{}, err
}
item.Scope = robotdomain.ScopeGame
return item, nil
}
func (r *Repository) PickRoomRobot(ctx context.Context, appCode string, roomScene string, roomID string, nowMS int64) (robotdomain.Robot, error) {
app := appcode.Normalize(appCode)
roomScene = robotdomain.NormalizeRoomScene(roomScene)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return robotdomain.Robot{}, err
}
defer func() { _ = tx.Rollback() }()
var item robotdomain.Robot
err = tx.QueryRowContext(ctx,
`SELECT app_code, room_scene, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
FROM robot_room_robots
WHERE app_code = ? AND room_scene = ? AND status = ?
ORDER BY RAND()
LIMIT 1
FOR UPDATE SKIP LOCKED`,
app, roomScene, robotdomain.StatusActive,
).Scan(&item.AppCode, &item.RoomScene, &item.UserID, &item.Status, &item.CreatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.LastUsedAtMS, &item.UsedCount)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "room robot not found")
}
return robotdomain.Robot{}, err
}
res, err := tx.ExecContext(ctx,
`UPDATE robot_room_robots SET last_used_at_ms = ?, used_count = used_count + 1 WHERE app_code = ? AND room_scene = ? AND user_id = ? AND status = ?`,
nowMS, app, roomScene, item.UserID, robotdomain.StatusActive,
)
if err != nil {
return robotdomain.Robot{}, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "room robot not found")
}
if err := tx.Commit(); err != nil {
return robotdomain.Robot{}, err
}
item.Scope = robotdomain.ScopeRoom
item.LastUsedAtMS = nowMS
item.UsedCount++
return item, nil
}
func (r *Repository) ListRoomRobots(ctx context.Context, appCode string, roomScene string, status string, pageSize int32, cursor string) ([]robotdomain.Robot, string, error) {
app := appcode.Normalize(appCode)
roomScene = robotdomain.NormalizeRoomScene(roomScene)
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
status = strings.TrimSpace(status)
cursorID := int64(0)
if strings.TrimSpace(cursor) != "" {
_, _ = fmt.Sscan(strings.TrimSpace(cursor), &cursorID)
}
args := []any{app, roomScene}
query := `SELECT app_code, room_scene, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
FROM robot_room_robots
WHERE app_code = ? AND room_scene = ?`
if cursorID > 0 {
query += ` AND user_id > ?`
args = append(args, cursorID)
}
query += ` AND (? = '' OR status = ?) ORDER BY user_id ASC LIMIT ?`
args = append(args, status, status, int(pageSize)+1)
return scanRobotList(ctx, r.db, robotdomain.ScopeRoom, query, args, pageSize)
}
func (r *Repository) RegisterRoomRobots(ctx context.Context, appCode string, roomScene string, userIDs []int64, adminID int64, nowMS int64) ([]robotdomain.Robot, error) {
app := appcode.Normalize(appCode)
roomScene = robotdomain.NormalizeRoomScene(roomScene)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
for _, userID := range uniquePositiveInt64(userIDs) {
if _, err := tx.ExecContext(ctx,
`INSERT INTO robot_room_robots (
app_code, room_scene, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)
ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`,
app, roomScene, userID, robotdomain.StatusActive, adminID, nowMS, nowMS,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
items, _, err := r.ListRoomRobots(ctx, app, roomScene, "", 200, "")
return items, err
}
func (r *Repository) SetRoomRobotStatus(ctx context.Context, appCode string, roomScene string, userID int64, status string, nowMS int64) (robotdomain.Robot, error) {
app := appcode.Normalize(appCode)
roomScene = robotdomain.NormalizeRoomScene(roomScene)
status = robotdomain.NormalizeStatus(status)
if status != robotdomain.StatusActive && status != robotdomain.StatusDisabled {
return robotdomain.Robot{}, xerr.New(xerr.InvalidArgument, "room robot status is invalid")
}
res, err := r.db.ExecContext(ctx,
`UPDATE robot_room_robots SET status = ?, updated_at_ms = ? WHERE app_code = ? AND room_scene = ? AND user_id = ?`,
status, nowMS, app, roomScene, userID,
)
if err != nil {
return robotdomain.Robot{}, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "room robot not found")
}
return r.getRoomRobot(ctx, app, roomScene, userID)
}
func (r *Repository) DeleteRoomRobot(ctx context.Context, appCode string, roomScene string, userID int64) error {
app := appcode.Normalize(appCode)
roomScene = robotdomain.NormalizeRoomScene(roomScene)
res, err := r.db.ExecContext(ctx, `DELETE FROM robot_room_robots WHERE app_code = ? AND room_scene = ? AND user_id = ?`, app, roomScene, userID)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return xerr.New(xerr.NotFound, "room robot not found")
}
return nil
}
func (r *Repository) getRoomRobot(ctx context.Context, appCode string, roomScene string, userID int64) (robotdomain.Robot, error) {
var item robotdomain.Robot
err := r.db.QueryRowContext(ctx,
`SELECT app_code, room_scene, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
FROM robot_room_robots WHERE app_code = ? AND room_scene = ? AND user_id = ?`,
appCode, roomScene, userID,
).Scan(&item.AppCode, &item.RoomScene, &item.UserID, &item.Status, &item.CreatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.LastUsedAtMS, &item.UsedCount)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return robotdomain.Robot{}, xerr.New(xerr.NotFound, "room robot not found")
}
return robotdomain.Robot{}, err
}
item.Scope = robotdomain.ScopeRoom
return item, nil
}
func (r *Repository) dropIndexIfExists(ctx context.Context, tableName string, indexName string) error {
var count int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`,
tableName, indexName,
).Scan(&count); err != nil {
return err
}
if count == 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName)
return err
}
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) migrationCompleted(ctx context.Context, key string) (bool, error) {
var count int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM robot_service_migrations WHERE migration_key = ? AND status = ?`,
key, "completed",
).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) tableExistsInSchema(ctx context.Context, schemaName string, tableName string) (bool, error) {
var count int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
schemaName, tableName,
).Scan(&count); err != nil {
return false, err
}
return count > 0, nil
}
func quoteQualifiedIdentifier(schemaName string, tableName string) string {
return quoteIdentifier(schemaName) + "." + quoteIdentifier(tableName)
}
func quoteIdentifier(identifier string) string {
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
}
type queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}
func scanRobotList(ctx context.Context, db queryer, scope string, query string, args []any, pageSize int32) ([]robotdomain.Robot, string, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := []robotdomain.Robot{}
for rows.Next() {
var item robotdomain.Robot
if scope == robotdomain.ScopeRoom {
if err := rows.Scan(&item.AppCode, &item.RoomScene, &item.UserID, &item.Status, &item.CreatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.LastUsedAtMS, &item.UsedCount); err != nil {
return nil, "", err
}
} else {
if err := rows.Scan(&item.AppCode, &item.GameID, &item.UserID, &item.Status, &item.CreatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.LastUsedAtMS, &item.UsedCount); err != nil {
return nil, "", err
}
}
item.Scope = scope
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
nextCursor := ""
if len(items) > int(pageSize) {
nextCursor = fmt.Sprintf("%d", items[pageSize-1].UserID)
items = items[:pageSize]
}
return items, nextCursor, nil
}
func uniquePositiveInt64(values []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func int64SliceToAny(values []int64) []any {
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}