Files
Scoreboard/server/internal/store/migrate.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

150 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}