package store import ( "database/sql" "errors" "fmt" "io/fs" "log/slog" "sort" "strings" "git.yeij.top/AskaEth/Scoreboard/server/migrations" ) // 迁移记账用的表名,出现在报错里给现场照抄,所以单独提成常量。 const migrationsTable = "schema_migrations" const createMigrationsTable = `CREATE TABLE IF NOT EXISTS ` + migrationsTable + ` ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )` // Migrate 按文件名顺序迁移系统库(data/system.db)。 func (s *Store) Migrate() error { fsys, err := fs.Sub(migrations.SystemFS, "system") if err != nil { return fmt.Errorf("定位系统库迁移目录: %w", err) } return migrate(s.db, fsys, "系统库") } // MigrateEvent 迁移赛事库(event.db)。赛事库由调用方自行打开, // 本函数只负责执行 event/ 下的迁移,不持有连接。 func MigrateEvent(db *sql.DB) error { fsys, err := fs.Sub(migrations.EventFS, "event") if err != nil { return fmt.Errorf("定位赛事库迁移目录: %w", err) } return migrate(db, fsys, "赛事库") } // migrate 执行 fsys 中尚未应用的迁移,每个迁移在独立事务中完成。 // label 是给人看的库名(「系统库」/「赛事库」),会出现在每一条报错里: // 现场拿到一句「迁移失败」时,第一件要知道的就是**哪个库、哪个文件**。 func migrate(db *sql.DB, fsys fs.FS, label string) error { if _, err := db.Exec(createMigrationsTable); err != nil { return fmt.Errorf("%s:创建 %s 表失败: %w", label, migrationsTable, err) } applied, err := appliedMigrations(db) if err != nil { return fmt.Errorf("%s:%w", label, err) } names, err := fs.Glob(fsys, "*.sql") if err != nil { return err } sort.Strings(names) for _, name := range names { if applied[name] { continue } body, err := fs.ReadFile(fsys, name) if err != nil { return fmt.Errorf("%s:读取迁移文件 %s 失败: %w", label, name, err) } if err := applyMigration(db, name, string(body)); err != nil { return migrateFailure(label, name, err) } slog.Info("已应用迁移", "db", label, "name", name) } return nil } // migrateFailure 把一条迁移的失败翻译成现场能照着做的报错。 // // 为什么要专门做这件事:migrate 只按文件名记账(schema_migrations.name),没有校验和, // 而历史迁移里有一批 `ALTER TABLE ... ADD COLUMN` 这种**不可重放**的语句 // (0013/0014/0015/0018/0019/0020)。一旦 schema_migrations 少了一行 // (手工删行、从别处拷库、把老库文件换回来),下次启动就会把已经执行过的迁移再跑一遍, // 于是启动直接失败在「duplicate column name: ...」上:现场只看到一句英文 SQL 错误, // 不知道该改哪儿,也不知道该不该动库。 // // 这里是**低成本护栏**,只做两件不需要动迁移机制的事: // - 报错点名「哪个库 + 哪个文件」; // - 失败长得像「重复执行」时,把成因和两种处置写清楚(补记一行 / 用备份恢复)。 // // 真正的修法(记账加校验和 + 迁移本身写成可重放)要改迁移机制,见审查清单 A08, // 留待专门一轮。 func migrateFailure(label, name string, err error) error { msg := fmt.Sprintf("%s:迁移 %s 执行失败: %v", label, name, err) if replayLike(err) { msg += ";这条迁移很可能已经执行过,只是 " + migrationsTable + " 里没有它的记录" + "(手工删过行 / 从别处拷来的库 / 换回了旧库文件)。确认库结构确实已经包含这次改动后," + "补记一行再重启:" + fmt.Sprintf("INSERT INTO %s(name) VALUES('%s');", migrationsTable, name) + "(若不确定,用备份恢复整库;不要修改已经跑过的迁移文件。)" } return errors.New(msg) } // replayLike 判断这次失败是否像「把不可重放的迁移又执行了一遍」。 // SQLite 的原文在这两种情况下是固定的:ALTER TABLE 重复加列报 duplicate column name, // CREATE 重名报 already exists。只认这两句,别的失败(锁超时、SQL 写错、库损坏)照原样报。 func replayLike(err error) bool { if err == nil { return false } s := err.Error() return strings.Contains(s, "duplicate column name") || strings.Contains(s, "already exists") } func appliedMigrations(db *sql.DB) (map[string]bool, error) { rows, err := db.Query(`SELECT name FROM schema_migrations`) if err != nil { return nil, fmt.Errorf("查询迁移记录: %w", err) } defer func() { _ = rows.Close() }() applied := make(map[string]bool) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } applied[name] = true } if err := rows.Err(); err != nil { return nil, err } return applied, nil } func applyMigration(db *sql.DB, name, body string) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("开启事务: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.Exec(body); err != nil { return fmt.Errorf("执行迁移 %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, name); err != nil { return fmt.Errorf("记录迁移 %s: %w", name, err) } return tx.Commit() }