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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

+80 -14
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -24,8 +25,27 @@ import (
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
const maxProjectImportBytes = 512 << 20
// maxProjectImportBytes 是赛事包上传的硬上限。
//
// **故意与另外两个批量导入(名单 CSV 32MB、基础数据包 32MB)不同**:赛事包是唯一
// 带二进制资源的包——库里导出 + 赛事会标 / 大屏背景图 / 单位 logo,以及**字体**
// (字体上传的单文件上限本身就是 20MB,见 fonts.go 的 maxFontBytes,且允许传多个)。
// 按 32MB 卡会让「两个字体 + 一张背景图」的正常赛事包直接导不进来,属于生产事故。
//
// 真正防「整个包驻留内存」的是 ParseMultipartForm 的 maxMemory(见 multipartMemoryBytes):
// 超出部分 Go 会自动落到临时文件,所以上限放宽不等于内存放大。
//
// 它是 var 而不是 const,只为给测试留一个「临时调小再验拒绝路径」的口子
// (见 SetMaxProjectImportBytesForTest);生产代码没有任何改动它的入口。
var maxProjectImportBytes int64 = 512 << 20
// SetMaxProjectImportBytesForTest 临时调整赛事包上限,返回原值便于 defer 还原。
// 仅供测试使用;调用方不要在 t.Parallel() 的用例里用它。
func SetMaxProjectImportBytesForTest(v int64) int64 {
prev := maxProjectImportBytes
maxProjectImportBytes = v
return prev
}
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
@@ -34,7 +54,7 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
// 单位奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
@@ -135,13 +155,13 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any,
})
}
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
// exportMedalsXLSX 导出单位奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return s.streamDownload(w, "medals.xlsx", "单位奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteMedalDetailXLSX(out, wb)
})
}
@@ -249,7 +269,7 @@ type baseDataChange struct {
// baseDataImportResult 是差异预览 / 导入落地的结果。
// 三类计数互不重叠:added + updated + unchanged = 表里有效数据行数;
// errorCount 是另计的被拦下的行(它们不写库)。
// errorCount 是另计的被拦下的行(它们不写库),命中「已彻底删除」的单位也在这一档里。
type baseDataImportResult struct {
DryRun bool `json:"dryRun"`
Added int `json:"added"`
@@ -270,7 +290,7 @@ type baseDataImportResult struct {
// 表里没有的行一律不动——导入永远不做删除。
func (s *server) importBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxBaseDataImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
@@ -325,6 +345,8 @@ type baseDataExistingTeam struct {
color *string
sortOrder int
active bool
// deleted 表示这行是「已彻底删除」(teams.deleted_at 非空)的单位:库里有、界面上没有。
deleted bool
}
type baseDataExistingSport struct {
@@ -495,8 +517,10 @@ func (s *server) planBaseDataImport(ctx context.Context, tables []export.BaseDat
func (p *baseDataPlanner) loadExisting() error {
// 单位连「彻底删除」(deleted_at 非空)的一起读:编码有唯一索引,不认它就会在 INSERT 时撞索引。
// 更新不会碰 deleted_at,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。
// deleted_at 也一并读出来:命中隐藏单位时要把它跳掉并明确告诉用户(见 planTeams),
// 只按「更新」走的话现场改完名字界面上仍然没有它,看起来像导入没生效。
teamRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT id, code, name, short_name, color, sort_order, active
SELECT id, code, name, short_name, color, sort_order, active, deleted_at IS NOT NULL
FROM teams ORDER BY sort_order, code`)
if err != nil {
return internal(err)
@@ -508,11 +532,12 @@ func (p *baseDataPlanner) loadExisting() error {
code string
shortName, color sql.NullString
)
var active int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active); err != nil {
var active, deleted int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active, &deleted); err != nil {
return internal(err)
}
t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0
t.deleted = deleted != 0
p.teams[code] = t
}
if err := teamRows.Err(); err != nil {
@@ -623,6 +648,16 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
if !ok {
existing = baseDataExistingTeam{active: true}
}
// 命中「已彻底删除」(deleted_at 非空)的单位:库里那行还在、界面上和导出件里都没有它。
// 旧行为是照常走「更新 / 无变化」并真的写库,于是现场会看到「更新 1 条」却怎么也找不到这个单位
// (它仍是隐藏状态),像是导入没生效。改成整行跳过 + 单独报一句原因与恢复入口。
// 文案只指界面路径:这句话会直接显示给现场操作员,内部接口路径对他们没有意义。
if ok && existing.deleted {
p.rowError(sheet, line,
"单位 %s 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先到「基础数据 → 参赛单位」勾选「显示已彻底删除的单位」后点「恢复」",
code)
continue
}
plan := baseDataTeamPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
if row.Has["active"] {
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
@@ -1400,9 +1435,17 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error)
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
// multipartMemoryBytes 是所有 multipart 上传共用的**内存档位**(不是大小上限):
// 解析时最多在内存里留这么多,超出部分 Go 自动落到临时文件。
// 防「整个包驻留内存」靠的是它,所以各入口的大小上限可以按内容类型各不相同。
const multipartMemoryBytes = 16 << 20
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
if err := r.ParseMultipartForm(512 << 20); err != nil {
// 内存里只留 multipartMemoryBytes,超出的部分由 multipart 落在系统临时目录。
// 注意别把 maxMemory 与上面的大小上限搞混:上限可以宽(赛事包带字体/图片),
// 内存档位必须窄。
if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil {
return nil, badRequest("解析上传失败", nil)
}
file, _, err := r.FormFile("file")
@@ -1496,6 +1539,19 @@ type backupRestoreResult struct {
Warning string `json:"warning,omitempty"`
}
// restoreMu 串行化「一键恢复」。
//
// 恢复动的是数据目录里的库文件本身(关连接 → 整文件替换 → 触发重启),
// 同一次操作里没有任何一步能并发安全地跑两遍:两个管理员同时点(连点两次、两个窗口、
// 两个人各按一次)会让两次替换交错——一个刚拍完安全副本、另一个已经换了文件,
// 回执还会各说各话(一个说已用 A 恢复、另一个说已用 B 恢复),而现场只看到「恢复了」。
//
// 与备份侧 export.Service.mu 是同一套做法(同一时刻只让一个动库文件的操作在跑),
// 区别在这里是全局一把:要保护的是数据目录里的库文件,它不属于某一个 server 实例。
// 后来者不排队静默执行——排队会让请求一直挂着,页面上的「正在重启」会变成假象,
// 所以用 TryLock 当场返回「正在恢复,请稍后」。
var restoreMu sync.Mutex
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
//
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
@@ -1510,6 +1566,12 @@ type backupRestoreResult struct {
// → 关连接、替换文件
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
// 整段串行化(含参数校验):忙的时候直接回话,连文件都不碰。
if !restoreMu.TryLock() {
return nil, conflict("正在恢复,请稍后:上一次恢复还在进行,等它重启完成、页面恢复后再试")
}
defer restoreMu.Unlock()
var in restoreBackupPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
@@ -1629,10 +1691,14 @@ func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (a
} else {
base += ";服务端正在重启,请稍候刷新页面"
}
notes := []string{"恢复前安全副本:" + safety.Name}
// 安全副本自己那份保留策略要固定说清楚:用户文档只承诺「不参与 20 份滚动清理」,
// 现场很容易理解成「这些副本会一直在」。实际上每类库只留最近 DefaultSafetyCopyKeep 份,
// 第 N 次恢复会把最早的删掉——不写死这句话,想退回三周前那个时间点时会扑空。
// 但只在真清理了才说「已清理 N 份」:没删就说删了也是假话。
notes := []string{fmt.Sprintf("恢复前安全副本:%s(每类库保留最近 %d 份,更旧的会被后续恢复清理)",
safety.Name, export.DefaultSafetyCopyKeep)}
if pruned > 0 {
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
pruned, export.DefaultSafetyCopyKeep))
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本", pruned))
}
if kind == export.RestoreKindEvent {
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。