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 }