feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复

用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
This commit is contained in:
AskaEth committed 2026-09-11 19:26:36 +08:00
1 parent 631c8dd8e1
commit 53a0cb225c
61 files changed
+6386 -699

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.1.6
dev_0.1.7
+48 -4
View File
@@ -10,9 +10,18 @@ REM The data dir is the one the Console uses, i.e. %LOCALAPPDATA%\ScoreboardCon
REM (falling back to <root>\data when that does not exist yet). It lives outside
REM the install dir on purpose: reinstalling over an existing install wipes the
REM install dir, so data must not be kept there.
REM NOTE: only .db snapshots are taken. Background images / emblems under
REM <data dir>\event_projs\<project>\uploads\ are NOT included; copy that
REM directory separately if you need them on the archive.
REM NOTE: the .db snapshots below are not the whole event. Uploaded image assets
REM (event logo, display background/logo, team logos) and uploaded fonts
REM live as files under <data dir>\event_projs\<project>\uploads\ and are
REM referenced from the DB by path, so this script also zips each
REM project's uploads\ into <data dir>\backups\ next to its snapshots.
REM Logs under <data dir>\logs\ are NOT archived: the server rotates them
REM (log.* in config.yaml), they can grow without bound, and they are
REM diagnostics rather than data -- copy that directory by hand when a
REM post-mortem needs it.
REM The uploads zips are NOT pruned by the server (its 20-file rotation
REM only counts .db files), so delete old *-uploads-*.zip by hand when
REM they pile up -- keep at least the newest one per project.
REM
REM Usage: deploy\backup.bat manual backup (reason=manual)
REM deploy\backup.bat publish pre-publish backup (reason=publish)
@@ -92,13 +101,48 @@ if not defined FOUND (
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
)
REM Archive each project's uploads\ (images + uploaded fonts under
REM uploads\fonts\). One timestamp is reused for every project so the snapshots
REM of a single run sort together.
set /a ASSETS=0
set /a ASSETS_FAIL=0
for /f %%T in ('powershell -NoProfile -NonInteractive -Command "Get-Date -Format yyyyMMdd-HHmmss"') do set "STAMP=%%T"
if not defined STAMP set "STAMP=unknown"
for /d %%D in ("%DATA%\event_projs\*") do call :archive_uploads "%%~nxD" "%%~fD"
echo.
echo === backup done: ok=!OK! fail=!FAIL! ===
echo === backup done: ok=!OK! fail=!FAIL! assets=!ASSETS! assets_fail=!ASSETS_FAIL! ===
dir /b "%DATA%\backups\*.db" 2>nul
dir /b "%DATA%\backups\*-uploads-*.zip" 2>nul
if !FAIL! GTR 0 (
endlocal
exit /b 1
)
if !ASSETS_FAIL! GTR 0 (
endlocal
exit /b 1
)
endlocal
exit /b 0
REM ==== subroutines ===========================================================
REM %1 = project name, %2 = project dir. The archive keeps the top-level
REM "uploads" folder name, so unzipping into a project dir puts the files back
REM exactly where the server reads them from.
:archive_uploads
set "HASFILES="
for /f "delims=" %%F in ('dir /b /s /a-d "%~2\uploads" 2^>nul') do set "HASFILES=1"
if not defined HASFILES (
echo no assets %~1\uploads (empty, skipped)
exit /b 0
)
echo archive assets %~1\uploads
powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; Compress-Archive -Path '%~2\uploads' -DestinationPath '%DATA%\backups\%~1-uploads-%STAMP%.zip' -Force"
if errorlevel 1 (
echo [ERROR] archiving %~1\uploads failed
set /a ASSETS_FAIL+=1
) else (
set /a ASSETS+=1
)
exit /b 0
+9 -3
View File
@@ -6,11 +6,12 @@
| 文件 | 用途 |
| --- | --- |
| `Scoreboard Console-0.1.1-x64.exe` | NSIS 安装包(推荐),双击按向导装 |
| `Scoreboard Console-0.1.1-x64.zip` | 免安装包,解压即用 |
| `Scoreboard Console-<版本>-x64.exe` | NSIS 安装包(推荐),双击按向导装 |
| `Scoreboard Console-<版本>-x64.zip` | 免安装包,解压即用 |
| `scoreboard.exe` | 服务端本体(已内嵌前端页面) |
> 校验完整性:`certutil -hashfile "Scoreboard Console-0.1.1-x64.exe" SHA256`
> **版本号以 `dist\console\` 下的实际文件名为准**(构建时从仓库根 `VERSION` 生成,不在这里写死)。
> 校验完整性:`certutil -hashfile "Scoreboard Console-<版本>-x64.exe" SHA256`(`<版本>` 照 `dist\console\` 里的实际文件名填)
## 2. 目标机(服务端主机)
@@ -35,6 +36,8 @@
- [ ] 「系统设置 → 赛事信息」的**基本信息**:填名称、副标题、主办方,上传**赛事会标**
- [ ] 同页的**大屏配置**:上传大屏会标、选大屏背景图,填大屏标题 / 副标题、轮播间隔与主题色(上传的图片随赛事项目包一起导出/导入)
- [ ] 同页的**字体**:确认**标题字体**与**正文字体**已选好(默认、四款内置字体,或自己上传的字体);选完点「保存」,并核对大屏上标题与正文显示正常。上传的字体存在赛事项目的 `uploads\fonts\` 里,**导出赛事项目包会一并带走、导入后不用重新上传**(见使用手册 14.3、部署指南 8.5)
- [ ] **登录有效期按赛程设好**:到「系统设置 → 登录有效期」把有效期设成覆盖本次比赛全部时段的时长(默认 **24 小时**、可调 **1–720** 小时,跨天比赛建议 **72 小时**),并核对页面顶部「当前会话将于 … 过期」;比赛期间操作员长时间不操作也不会掉线,但它不是「永不掉线」(见使用手册 14.4、常见问题 Q89)
- [ ] **收表口径已交代**:提醒各单位报名名单里的**号码布**是系统认人的唯一标识、不能重号(重号行会被跳过);证件号可选、但也不能与别人重号(见报名名单导入规范)
- [ ] **赛事项目确认 / 导入**:到「导出 → 赛事项目包」核对**赛事项目列表**里带「当前」徽标的是本次要用的赛事;需要换赛事时先「导入 .zip」,再点「切换为当前赛事」(服务端会重启一次,页面显示「正在重启服务端…」,恢复后自动刷新)。账号与会话在数据目录的 `system.db` 里,**不受切换影响**,一般不用重新登录。这一步做完再往下走,否则后面看到的是别的赛事的数据
- [ ] **导入规则包**:`%LOCALAPPDATA%\ScoreboardConsole\rules\2026-驭电利刃杯军事运动会.yaml`,走「导出 → 规则包 → 选择 YAML → 预览差异 → 确认导入并全量重算」(规则目录在用户数据目录里,见部署指南 3.5)
—— 服务端不会自动加载规则包,必须导入一次
@@ -55,6 +58,8 @@
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏)
- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布)
- [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8)
- [ ] **报名名单导出演练**:「导出 → 结果导出 → 报名名单」按小项 / 组别 / 单位 / 批次筛一次并导出,核对 13 列与状态中文(正常参赛 / 已退赛 / 取消资格 / 未出场)与录入页一致
## 6. 数据与备份
@@ -63,6 +68,7 @@
- [ ] 开幕前手动点一次「立即备份」(「系统设置 → 备份 → 立即备份」)
- [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图、单位 logo 与上传的字体)拷一份到 U 盘
- [ ] 留一份成绩总表存档:到「导出 → 结果导出」导一份**全部(单页)**(`赛事成绩总表(单页).xlsx`,一个工作簿一张表,适合比对与筛选),需要归档打印再导一份「全部」(多 sheet)
- [ ] 导出并留档一份**基础数据包**:「导出 → 基础数据 → 导出 xlsx」得到含**单位 / 大项 / 小项 / 组别**四张工作表的工作簿;它可按编码原样导回(存在就更新、绝不删除),赛前改乱基础数据时可以拿它对照 / 回灌
## 7. 现场流程演练(建议至少一次)
+39 -18
View File
@@ -172,7 +172,7 @@
> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。
**如果管理员关掉了「分批发布时实时更新榜单」**(见 10.5),行为会变成这样:发布后**成绩照常上屏,但名次、积分与奖牌榜都不动**——要等**该项目该组所有人成绩都录完**(退赛 / 失格的人不算在等待里)才一次性结算并更新榜单。未结算期间:
**如果管理员关掉了「分批发布时实时更新榜单」**(见 10.5),行为会变成这样:发布后**成绩照常上屏,但名次、积分与奖牌榜都不动**——要等**该项目该组所有「正常参赛」的人都录完成绩**才一次性结算并更新榜单(退赛 / 取消资格 / 未出场的人不算在等待里)。未结算期间:
- 录入页在进度条下方显示一条提示:`延迟结算:本组 X/Y 人已出成绩,名次与榜单等该组全部录完后一起更新`(本页预览的名次仍是实时的),旁边就是「立即结算」按钮;
- 该组在**分项成绩 / 最新成绩里的名次显示 `—`**(成绩照常显示)——这是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面;
@@ -370,17 +370,19 @@
| --- | --- | --- | --- | --- |
| 启用(默认) | 在 | 能 | 保留 | —— |
| 停用 | 还在(标「已停用」) | 仍可手动选(「全选」不会自动带上它) | **保留** | 点「启用」即可 |
| 彻底删除 | **不在了** | 不能 | **保留** | 界面上没有恢复入口;再次导入用到它的编码会自动回来 |
| 彻底删除 | **默认不在列表里**(管理员勾选「显示已彻底删除的单位」后可见,标「已删除」) | 不能 | **保留** | **管理员可在列表里点「恢复」**;再次导入用到它的编码也会自动回来 |
「停用」还是软删除:单位留在列表里,历史成绩与积分流水保留,随时能再「启用」。「彻底删除」是**假删除**——只把这个单位从参赛单位列表里移除,它的报名、成绩、积分流水、人工调整、纪录与名下的运动员**一条都没删**,**积分榜也不会变化**,不会触发任何重算。操作步骤:
1. 点该行右侧红色的「彻底删除」;
2. 在确认框里核对提示(会写明该单位从列表与报名选择里移除、界面上无法恢复、已有报名与积分不受影响、积分榜不变),再点一次「彻底删除」;
3. 完成后该单位从列表里消失,页面提示「已彻底删除「XX」」。
2. 在确认框里核对提示(会写明该单位从列表与报名选择里移除、已有报名与积分不受影响、积分榜不变,以及恢复要找谁),再点一次「彻底删除」;
3. 完成后该单位从默认列表里消失,页面提示「已彻底删除「XX」」。
**怎么恢复**:只有**管理员**能在参赛单位页勾选「**显示已彻底删除的单位**」把它们翻出来,点该行的「**恢复**」即可。恢复是幂等的——它本来就还在时会提示「本就未被彻底删除」;如果这个编码已经被另一个可见单位占用(只能来自手工改库),会拒绝并说明原因。**录入员(recorder)界面上看不到这个开关**,删错了要**找管理员恢复**(删除确认框里也写了这句)。
**「彻底删除」之后会发生什么**
- 该单位**不再出现在参赛单位列表**里,报名选择(含团体报名、运动员的「所属单位」下拉)里也选不到,界面上没有恢复入口;
- 该单位**不再出现在默认的参赛单位列表**里,报名选择(含团体报名、运动员的「所属单位」下拉)里也选不到;
- 它的**报名、成绩、积分流水、人工调整、纪录与名下运动员全部保留**——消失的只是这个单位本身;
- **积分榜照旧显示它**:单位本来有分数的话,总积分榜、团体奖牌榜上仍有它的名字与分数。这是刻意设计(数据没删,分数就不该凭空消失),不是故障;
- **再次导入报名表时,如果用到这个单位的编码,它会自动复活**并重新出现在列表里——这里和人员不同:删掉的人再次导入是**以新档案建档**(旧数据回不来),删掉的单位则是**原来那条记录直接恢复**,报名、成绩与积分一直都在(见 10.7);
@@ -421,9 +423,9 @@
### 10.3 组别
1. 先在上方选中一个小项;
2. 点「新增组别」,填写名称、权重、性别、年龄上下限;
2. 点「新增组别」,填写名称、权重、性别、年龄上下限、排序;
3. 用「上移 / 下移」调整顺序,「移除」删除;
4. 点「保存全部」整体替换该小项的组别配置。**若该项目已有报名数据,服务端会拒绝替换。**
4. 点「保存」提交。**已有报名数据的小项**:改名 / 改年龄 / 改性别 / 改权重 / 改排序会**就地保存**——组别本身不重建,挂在它下面的报名、成绩与积分流水一条不动;其中**改权重会立即按新权重重算该项目已发布成绩的积分与榜单**,改名 / 改年龄 / 改性别 / 改排序不会。**新增或删除组别**仍走「整体替换」,有报名数据时服务端会拒绝(组别一重建,挂在它下面的报名就断链了)——要增删请先撤掉增删操作、只保存字段修改。
> **组别之下还有「批次」**:批次(`第 1 批`、`第 2 批`…)是在**成绩录入页**给报名行分的录入 / 展示分组,不属于这里的组别配置,也**不参与算分**——名次与积分仍按「小项 + 组别」整体计算。怎么分批见 4.1。
@@ -448,7 +450,7 @@
**同一张卡片里的「分批发布时实时更新榜单」开关(默认开)**
- **开(默认)**:每发布一批成绩就立即重算名次、各单位总分与奖牌榜,即 4.6 描述的行为;
- **关** = **延迟结算**:分批发布时**只把成绩上屏,不动积分与名次**;等**该项目该组所有批次都录完**(退赛 / 失格的人不算在等待里)才一次性结算,更新榜单与奖牌。未结算的组在分项成绩 / 最新成绩里的**名次显示 `—`**(成绩照常显示)——这是有意的,否则会出现「有金牌但 0 分」的自相矛盾;
- **关** = **延迟结算**:分批发布时**只把成绩上屏,不动积分与名次**;等**该项目该组所有批次都录完**(报名状态不是「正常参赛」的人——退赛 / 取消资格 / 未出场——不算在等待里)才一次性结算,更新榜单与奖牌。未结算的组在分项成绩 / 最新成绩里的**名次显示 `—`**(成绩照常显示)——这是有意的,否则会出现「有金牌但 0 分」的自相矛盾;
- 有人永远录不出成绩时,到**成绩录入页**点 **「立即结算」** 兜底:按当前已发布成绩先结算一次,补录完还会再自动结算;
- 勾选 / 取消后点「保存结算方式」生效;只有管理员能改。
@@ -524,7 +526,7 @@
1. 先按 `bib`(号码布)匹配:命中即认作同一人;姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上时进「冲突」列表,等你逐条决定(见下);
2. 号码布为空、或按号码布没找到,再按「姓名 + 单位」找:
- 恰好一人、且他还没有号码布 → 认作同一人,表里给的号码布 / 证件号 / 年龄会**回填**进他的档案(不算冲突);
- 恰好一人、且他已有号码布(与表里不同)→ 按「**同名的另一个人**」新建档案,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒核对,**不算错误、不拦截**;
- 恰好一人、且他已有号码布 → 分两种:表里**没给号码布**(或与他相同)时**仍判同一人**(重传不带号码布的名单不会把每个人复制一份);表里**给了号码布且与他不同**时才按「**同名的另一个人**」新建档案,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒核对,**不算错误、不拦截**;
- 多人 → 分不清是谁,进「冲突」列表;
3. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来;
4. 证件号不参与认人,但**一个证件号只能属于一个人**:表里给的证件号若已被别人占用,该行会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过,不会把证件号抢过来;
@@ -571,7 +573,7 @@
| 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` |
- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导);
- **录了但还没发布的成绩也会占一行**(「分项成绩表」「全部」「全部(单页)」都是):这类行的成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」,人不会从表上消失;**完全没有录成绩的报名行**(连草稿都没有)保持原样——各列留空、状态显示「未录入」;
- **录了但还没发布的成绩也会占一行**(「分项成绩表」「全部」「全部(单页)」都是):这类行的成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」,人不会从表上消失;**完全没有录成绩的报名行**(连草稿都没有)**照样占一行**——名次 / 成绩 / 积分三列都写 `—`(不是空、也不是 `0`)、状态列写「未录入」;
- **「全部」与「全部(单页)」的区别**:两者内容同源,覆盖的都是全部小项的全部报名 / 成绩;「全部」把每个小项放成一张分表(多 sheet 工作簿),「全部(单页)」把所有行平铺进同一张表,每行前面补「大项 / 小项 / 组别」三列以便脱离表名也能看懂。单页表的列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注;
- **「团体奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响;
- 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。
@@ -616,7 +618,7 @@
「基础数据」页签有**导出**与**导入**两张卡片,用于批量维护基础数据(单位 / 大项 / 小项 / 组别)。
**导出基础数据包**:点「导出 xlsx」导出一个工作簿,含**四张工作表:单位 / 大项 / 小项 / 组别**(组别表带所属小项编码)。列名是中文表头;导出的文件可以直接原样导回来,识别到的差异为 0。
**导出基础数据包**:点「导出 xlsx」导出一个工作簿,含**四张工作表:单位 / 大项 / 小项 / 组别**(组别表带所属小项编码)。列名是中文表头;导出的文件可以直接原样导回来,识别到的差异为 0。导出是**只读下载**,不需要管理员权限(访客也能用)。
**导入基础数据包**(**仅管理员**):
@@ -628,7 +630,7 @@
- **按编码匹配**:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;**存在就更新、不存在就新建**;
- **绝不删除**:文件里没有的行(表里没有、或整张工作表没放)**不会被删掉**,也不会被停用;
- **列不在表里 = 不动该字段**;**列在表里但格子留空 = 按该列的默认口径处理**:简称 / 颜色 / 场地 / 备注等文本列与年龄上下限会**清空**,权重留空按 1、启用 / 计分 / 计破纪录等布尔列留空按「是」,**排序留空则保持原来的值**。改数据前先想清楚这一点;
- **列不在表里 = 不动该字段**(**布尔列也一样**——把整列「启用」删掉再导回,**不会**把所有停用的单位/大项改成启用);**列在表里但格子留空 = 按该列的默认口径处理**:简称 / 颜色 / 场地 / 备注等文本列与年龄上下限会**清空**,权重留空按 1、启用 / 计分 / 计破纪录等布尔列留空按「是」,**排序留空则保持原来的值**(**四张表口径一致**,单位 / 大项留空同样不会被改写)。改数据前先想清楚这一点;
- **表头行可以不在第一行**:从系统导出的包表头在第 4 行(上面是标题与副标题),手工整理的表表头在第 1 行,两种都能识别(识别范围是前 20 行);
- **枚举列要填系统里的英文规范值**(表头是中文,容易踩):类型 `individual` / `team` / `match` / `individual_team`,录入方式 `auto` / `manual`,成绩类型 `time` / `distance` / `count` / `score` / `none`,比较方式 `asc` / `desc` / `none`,小项状态 `pending` / `ongoing` / `finished` / `published`,平均口径 `valid` / `all`;布尔列(启用 / 计分 / 计破纪录)填 **是 / 否**;组别表的「性别」填 **男 / 女**(留空 = 不限);
- **最稳的用法**:先「导出基础数据包」,在导出件上直接改,再导回来——表头与取值天然对得上;手工整理的表格请照导出件的表头写;
@@ -651,10 +653,10 @@
审计日志,只读、不可删除,记录谁在什么时候改了什么。
1. 可按「动作」「实体」筛选,点「清除筛选」复位;
1. 可按「**动作**」「实体」筛选,点「清除筛选」复位。**动作是子串模糊匹配、由服务端在全库范围执行**(比如填 `entry.`、`import`、`team.purge` 都能筛出一批),**实体筛选与分页作用于返回的最近 200 条窗口**;
2. 列表显示时间、操作人、IP、动作、实体、请求 ID;关键动作会带「关键」标记;
3. 点某行「对比」展开,查看改动前后的 JSON 差异;
4. 筛选与分页作用于最近 200 条记录;
4. **动作筛选不受 200 条窗口限制**(服务端按全库匹配后再取回),实体筛选与翻页只在窗口内;
5. 该页面仅管理员可查看。
> 本页是**业务审计**(谁在什么时候改了什么),只记**放行**的操作。想看「某次操作为什么被系统拦下」——比如保存被拒、导入报错、切页提示离线——那要看服务端写在数据目录 `logs\` 下的**两类日志文件**(放行类 / 拦截类):我们主动拦下的报错会写明中文原因、拦截点 `文件:行 函数名`、操作人与 requestId。位置、轮转与复盘步骤见[部署指南 · 服务端日志与拦截复盘](./部署指南.md#10-服务端日志与拦截复盘)。
@@ -663,7 +665,7 @@
## 14. 系统设置
页面分四个页签:**用户管理** / **登录有效期** / **备份** / **赛事信息**。前三个需要管理员权限,非管理员只看到「赛事信息」(其余页签不显示)。
页面分五个页签:**用户管理** / **登录有效期** / **备份** / **图片资源** / **赛事信息**。前四个需要管理员权限,非管理员只看到「赛事信息」(其余页签不显示)。
### 14.1 用户管理
@@ -680,8 +682,8 @@
- 列表显示备份文件名、来源、大小、创建时间;
- 点「立即备份」马上生成一份;
- 服务端默认每 5 分钟自动备份一次,自动、手动、发布前备份都按 `backup.keep`(默认 20 份)滚动保留;
- 右侧「恢复说明」列出停机恢复步骤(详见部署指南第 8 节)。
- 服务端默认每 5 分钟自动备份一次,自动、手动、发布前备份都按 `backup.keep`(默认 20 份)滚动保留;⚠️ `deploy\backup.bat` 额外产出的 `*-uploads-*.zip`(上传图片与字体)**不在**这个滚动清理里,要手工清理;
- 每行备份都有**「恢复」**按钮(**仅管理员**):用那份备份覆盖当前库——服务端会先自动留一份「恢复前安全副本」(`pre-restore-…db`,不参与滚动清理),替换文件后优雅重启,页面自动等待刷新。**恢复是覆盖式的**(会丢备份时间点之后的改动),**恢复系统库会连账号与会话一起换掉**,**不会带回上传的图片与字体**;右侧「恢复说明」列出细节与停机手工恢复步骤(详见部署指南第 8 节)。
### 14.3 赛事信息
@@ -734,11 +736,30 @@
| 当前会话将于 X 过期 | 页面顶部与右上角用户菜单里都会显示当前登录的到期时间 |
- **滑动续期**:只要在有效期内继续操作(两次操作的间隔不超过设定时长),到期时间会自动往后顺延——**不会因为「登录满 N 小时」被踢**。因此比赛期间可以从早用到晚不重新登录,例如把有效期设成 **72 小时**(三天);
- **改有效期只对之后的新登录生效**:已经登录的会话保持各自的到期时间,不会被当场踢下线;
- **改有效期不会当场踢人**:已经登录的会话不会在改设置的一刻被判定过期(库里到期时间不动),它们会在**下一次操作时按新的有效期续期**——调长会立即顺延(例如从 12 小时调到 72 小时,正在用的会话下次有请求就顺延成 72 小时),调短也只影响之后的续期,不会把正在用的人下线;
- 页面开着不动时,管理端每 10 分钟会自动续期一次,所以只要页面还开着就不会掉线;
- ⚠️ **不是「永不掉线」**:如果超过**设定的有效期**(默认 24 小时)一直没有操作(例如浏览器关了、机器关机过夜),再打开时仍会要求重新登录。
- 忘记密码时用 `scoreboard.exe passwd` 重置(见常见问题「忘记密码怎么办」)。
### 14.5 图片资源(清理没在用的图)
「图片资源」页签(**仅管理员**)列出 `uploads\` 里**没有被任何配置引用**的图片(也就是「孤儿」),用来清掉换图后留下的旧文件。表格显示预览、文件名、大小、修改时间,可单张「删除」或勾选后「删除选中」;删除前有危险确认框,**删掉不可恢复**。
什么算「被引用」——下面四处任意一处指向它,它就不算孤儿、不会出现在列表里:
- 赛事会标、大屏会标、大屏背景图(「赛事信息」页签里的三个图片字段);
- 任何一个**参赛单位的单位图标**(`logo` 字段)——**已被「彻底删除」的单位也算**,因为它还能被恢复,logo 得留着。
用法(典型的「换图后清旧图」):
1. 在「赛事信息」或「参赛单位」里把对应字段改成新图(或清空)并**保存**,旧图就没人引用了;
2. 到「图片资源」页签点「刷新」,旧图会出现在孤儿列表里;
3. 核对预览确认是那张没用的图,勾选 → 「删除选中」→ 确认。
- 若某张图在你打开页面之后**刚被配到别处**(比如别人同时改了单位 logo),服务端会**拒绝删除并在结果里说明被谁引用**——这是有意的「宁可漏删、不误删」;
- 这个页签**只管图片**;上传的字体在「赛事信息 → 字体库」里各自删除(被标题 / 正文字体引用的不能删,见 14.3);
- 这些图片与字体都是**文件**,在赛事项目的 `uploads\` 里:**备份的 `.db` 快照与「一键恢复」都不含它们**(见 14.2、常见问题 Q37)。
---
## 15. 修改密码
+27 -11
View File
@@ -174,7 +174,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
**Q36:换一台机器还要重新传字体吗?**
不用。字体文件就在当前赛事项目的 `uploads\fonts\` 里,所以**导出赛事项目包(zip)会连字体一起带走**,导入到另一台机器后直接可用、不用重新上传;字体设置存在赛事库里,也随包走(见 Q43)。
只有一个小陷阱:`backups\` 里的 `.db` 快照**不含**字体这些上传文件(见 Q37),如果只拷数据库快照换机器,字体就没了——记得把 `event_projs\<项目名>\uploads\` 一起拷(见 Q54)。
只有一个小陷阱:`backups\` 里的 `.db` 快照**不含**字体这些上传文件(见 Q37),如果只拷数据库快照换机器,字体就没了——记得把 `event_projs\<项目名>\uploads\` 一起拷(见 Q41)。
---
@@ -185,16 +185,18 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
文件是数据库快照,命名形如 `system-<原因>-<时间戳>.db` 与 `event-<原因>-<时间戳>.db`:`system` 是系统库(账号、会话),`event` 是赛事库(成绩、积分、报名、基础数据),原因是 `auto` / `manual` / `publish`,时间戳是「年月日-时分秒」,例如 `event-publish-20260910-120500.db`。
> 备份里只有数据库,**上传的图片与字体**(赛事会标、大屏背景图、单位 logo、自定义字体)不在其中,它们在 `data\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),需要另外归档。
> 备份里只有数据库,**上传的图片与字体**(赛事会标、大屏背景图、单位 logo、自定义字体)不在其中,它们在 `data\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),需要另外归档。部署目录下的 `deploy\backup.bat` 除了 `.db` 快照,**还会把每个项目的 `uploads\` 打成 `<项目名>-uploads-<时间戳>.zip`**;该 zip **不在 20 份滚动清理范围内**(清理只认 `.db`),要手工删旧的(见 Q39)。「一键恢复」也只换数据库文件,不会带回图片。
**Q38:多久备份一次?**
服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"`(部署目录下的 `deploy\backup.bat` 已经带好这些路径)。另外每次发布成绩前会自动备一份。
**Q39:备份会自动删除旧的么?**
会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。
会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。⚠️ 但 `deploy\backup.bat` 另外产出的 `*-uploads-*.zip`(上传图片与字体)**不在这个滚动清理里**,不会被自动删,需要手工清理,建议每个项目至少留最新一份。
**Q40:怎么恢复备份?**
没有一键恢复接口,需停机手工替换数据库文件:在 Console 里停止服务,到 `<数据目录>\event_projs\<项目名>\` 把目标备份复制为 `event.db`(覆盖原文件),删掉同目录的 `event.db-wal` 与 `event.db-shm`,再启动服务,详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
**管理端有一键恢复(仅管理员)**:到「系统设置 → 备份」,在列表里找到要退回到的那份(`pre-restore-…` 是系统自动留的「恢复前安全副本」),点该行的**「恢复」**并过危险确认框。服务端会先给当前库拍一份安全副本,再替换库文件并优雅重启,页面自动等待刷新。「恢复系统库」会连账号与会话一起换掉,重启后需要用那个时间点的账号重新登录;**恢复不会带回上传的图片与字体**(它们在 `uploads\` 里,不在 `.db` 内)。恢复是覆盖式的,备份时间点之后的改动都会丢。
没有 Console 或恢复不了时的兜底是停机手工替换数据库文件:在 Console 里停止服务,到 `<数据目录>\event_projs\<项目名>\` 把目标备份复制为 `event.db`(覆盖原文件),删掉同目录的 `event.db-wal` 与 `event.db-shm`,再启动服务,详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
**Q41:换一台电脑怎么迁移?**
用户数据目录和安装目录都要带走:
@@ -306,7 +308,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
**Q58:删掉的人怎么又出现在名单里了?**
彻底删除之后,如果又在报名表里导入同一个人(同一个证件号或号码布),他会以**全新的档案**重新出现在运动员名单里——因为旧档案已经连同他的报名与成绩一起删掉了,系统按证件号 / 号码布找不到他,就当作新人员建档。所以名单里突然多出一个「以前删过的人」,通常就是某次导入带回来的。
彻底删除之后,如果又在报名表里导入同一个人(同一个号码布,或同一「单位 + 姓名」),他会以**全新的档案**重新出现在运动员名单里——因为旧档案已经连同他的报名与成绩一起删掉了,系统按「号码布 → 姓名 + 单位」找不到他(证件号不参与认人),就当作新人员建档。所以名单里突然多出一个「以前删过的人」,通常就是某次导入带回来的。
- 新出现的这条是**全新的记录**:以前的报名、成绩与积分不会回来,需要重新录;
- 不想他再出现,就让各单位不要把这个人填进报名表;确实不该有的,再「彻底删除」一次即可。
@@ -332,7 +334,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
**Q61:参赛单位怎么彻底删除?和删运动员一样吗?**
不一样,两个按钮同名、行为完全不同,现场别按错。「基础数据 → 参赛单位」每行有三个动作:**编辑**、**停用**(已停用的单位显示为「启用」)、**彻底删除**。点该行右侧红色的「彻底删除」,在确认框里核对提示后再点一次「彻底删除」即可,完成后该单位从列表里消失,页面提示「已彻底删除「XX」」。
不一样,两个按钮同名、行为完全不同,现场别按错。「基础数据 → 参赛单位」每行有三个动作:**编辑**、**停用**(已停用的单位显示为「启用」)、**彻底删除**。点该行右侧红色的「彻底删除」,在确认框里核对提示后再点一次「彻底删除」即可,完成后该单位从默认列表里消失,页面提示「已彻底删除「XX」」。
一句话概括:**删运动员是真删,删单位是假删**。
@@ -341,13 +343,16 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
| 数据 | 人员档案连同报名、成绩、积分流水、破纪录记录**一并从库里删掉** | **一条都不删**,报名 / 成绩 / 积分 / 人工调整 / 纪录 / 名下运动员全部保留 |
| 榜单 | **重算**受影响小项的排名与积分,删完榜单会变 | **积分榜不变**,不触发任何重算 |
| 列表 | 从运动员名册里消失 | 从参赛单位列表里消失 |
| 恢复 | 恢复不了 | 界面上没有恢复入口;再次导入用到它的编码会自动回来(见 Q62) |
| 恢复 | 恢复不了 | **管理员**可在参赛单位页勾选「显示已彻底删除的单位」,再点该行「恢复」(见 Q62);再次导入用到它的编码也会自动回来 |
想让某个单位暂时不参赛,用「停用」就够了:单位还在列表里,历史数据保留,随时能再启用。详细说明见[使用手册 · 10.1 参赛单位](./使用手册.md#101-参赛单位)。
**Q62:删掉的单位怎么又出现在列表里了?**
多半是导入带回来的:报名表里再次用到这个单位的编码(`team_code`),系统会把它**原样复活**——清掉删除标记、恢复成启用,重新出现在参赛单位列表里。因为单位编码是唯一的,系统按编码找到的就是原来那一行,所以**不会新建第二个同名单位**,原有的报名、成绩与积分也都还在。
两种常见原因:**管理员恢复了它**,或者**导入把它带回来了**。
- **恢复**:只有**管理员**能在参赛单位页勾选「**显示已彻底删除的单位**」把已删除的单位翻出来(标「已删除」),点该行的「**恢复**」即可原样放回列表;录入员(recorder)界面上看不到这个开关,删错了要**找管理员恢复**。恢复是幂等的,本来就还在时会提示「本就未被彻底删除」;若该编码已被另一个可见单位占用(只能来自手工改库),会拒绝并说明原因。
- **导入带回**:报名表里再次用到这个单位的编码(`team_code`),系统会把它**原样复活**——清掉删除标记、恢复成启用,重新出现在参赛单位列表里。因为单位编码是唯一的,系统按编码找到的就是原来那一行,所以**不会新建第二个同名单位**,原有的报名、成绩与积分也都还在。
这和人员的规则**看着像、结果不同**:删掉的人再次导入是**以全新档案建档**,旧数据不会回来(见 Q58);删掉的单位再次导入是**原来那个单位直接复活**,旧数据一直都在。
@@ -536,15 +541,15 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
- **导出**:一个工作簿四张工作表(单位 / 大项 / 小项 / 组别,组别表带所属小项编码);
- **导入**(**仅管理员**,只认 `.xlsx`):按编码匹配,**存在就更新、不存在就新建,绝不删除**(表里没有的行不会被删、也不会被停用)。先点「预览差异」看「新增 / 更新 / 无变化 / 错误」与逐行变化,确认后再点「**确认写入**」;**有错的行会跳过、合法的行照常写入**,行级错误是中文并带真实 Excel 行号(如 `单位 第 3 行:单位编码必填`)。
要点:**列不在表里 = 不动该字段**;**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。
要点:**列不在表里 = 不动该字段**(**布尔列也一样**,缺整列 ≠ 取默认值);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写)。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。
**Q89:登录会不会用着用着被踢下线?「登录有效期」怎么设?**
默认有效期 **24 小时**,管理员可在「系统设置 → 登录有效期」里改成 **1–720 小时**(最长 30 天);比赛期间建议设成 **72 小时**。会话是**滑动续期**的:只要在有效期内继续操作(页面开着也会定期自动续期),到期时间就往后顺延——**不会因为「登录满 N 小时」被踢**。当前到期时间在右上角用户菜单与系统设置页都写着「当前会话将于 X 过期」。
⚠️ 但它不是「永不掉线」:超过设定的有效期一直没操作(浏览器关掉、机器关机过夜),再打开仍要重新登录。改设置**只对之后的新登录生效**,已经登录的会话不会被当场踢下线。
⚠️ 但它不是「永不掉线」:超过设定的有效期一直没操作(浏览器关掉、机器关机过夜),再打开仍要重新登录。改设置不会把正在用的会话当场踢下线:已登录会话的到期时间不会在改设置的一刻改变,它会在**下一次操作时按新的有效期续期**(调长立即顺延,例如 12 小时改 72 小时后,正在用的会话下次有请求就顺延成 72 小时;调短也只影响之后的续期)。
**Q90:发布后成绩上屏了,为什么积分榜没变、名次还是「—」?**
多半是管理员把「榜单口径」里的**「分批发布时实时更新榜单」关掉了**(默认是开的),也就是**延迟结算**:分批发布时只把成绩上屏,**积分与名次等该项目该组所有批次都录完才一次性结算**(退赛 / 失格的人不算在等待里)。未结算组的**名次显示 `—`**(成绩照常显示)是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面。
多半是管理员把「榜单口径」里的**「分批发布时实时更新榜单」关掉了**(默认是开的),也就是**延迟结算**:分批发布时只把成绩上屏,**积分与名次等该项目该组所有批次都录完才一次性结算**(报名状态不是「正常参赛」的人——退赛 / 取消资格 / 未出场——不算在等待里)。未结算组的**名次显示 `—`**(成绩照常显示)是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面。
想让榜单立刻跟上:等该组录完会自动结算;确实有人永远录不出成绩时,在**成绩录入页**点 **「立即结算」**,按当前已发布成绩先结算一次。想改回实时结算:管理员到「基础数据 → 积分模板 → 榜单口径」,把「分批发布时实时更新榜单」勾上并点「保存结算方式」(见使用手册 10.5)。
@@ -564,3 +569,14 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
- **小项**:漏传「计分」「计破纪录」= **两个都默认打开**(以前会被静默建成「只排名不计分、不能破纪录」);更新时漏传同样沿用原值。
用**基础数据包**导入时同理:「启用 / 计分 / 计破纪录」列留空 = 「是」,这些列**不在表里 = 不改动**该字段(见 Q88)。
**Q93:换了大屏背景图 / 单位 logo 之后,旧图片还占着地方,能删掉吗?**
能,管理员到「**系统设置 → 图片资源**」页签清理:那里列出 `uploads\` 里**没有被任何配置引用**的图片(孤儿),可以单张「删除」或勾选后「删除选中」,删除前有危险确认框、**删掉不可恢复**。
「被引用」指被下面四处任意一处用着——**赛事会标 / 大屏会标 / 大屏背景图 / 任一参赛单位的单位图标**(**已被「彻底删除」的单位也算**,因为它还能恢复,logo 得留着)。所以想清掉一张旧图,正确顺序是:
1. 先在「赛事信息」(或参赛单位)里把那个字段换成新图、或清空,并**保存**——这一步做完旧图才「没人引用」;
2. 再回「图片资源」页签点「刷新」,旧图就会出现在孤儿列表里;
3. 核对预览后删除。若某张图在你打开页面之后刚被配到别处,服务端会**拒删并说明被谁引用**(宁可漏删、不误删)。
注意:这个页签**只管图片**,上传的字体在「赛事信息 → 字体库」里各自删除(被标题 / 正文字体引用的不能删);这些图片和字体都是文件,**备份的 `.db` 快照和「一键恢复」都不含它们**(见 Q37),要清点或搬迁请到 `data\event_projs\<项目名>\uploads\`。
+1 -1
View File
@@ -123,7 +123,7 @@
| 情况 | 系统怎么处理 |
|---|---|
| 表里的号码布在系统里已有 | 认作**同一个人**;若表里的姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上,会进「冲突」列表等你逐条决定(保留哪一侧),该行本轮不写入 |
| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:<br>· 找不到 → 新建人员档案;<br>· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;<br>· 恰好一人、且他**已有号码布** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);<br>· 恰好一人、但他已有号码布**且与表里不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;<br>· 多人 → 分不清是谁,进「冲突」列表等逐条决定 |
| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:<br>· 找不到 → 新建人员档案;<br>· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;<br>· 恰好一人、且他**已有号码布**,但表里**号码布为空或与他相同** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);<br>· 恰好一人、他已有号码布、**且表里给的号码布与他不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;<br>· 多人 → 分不清是谁,进「冲突」列表等逐条决定 |
> 「同名的另一个人」多半是号码布写错了。收到这条提醒时,先核对新号码布是不是本人:是别人(真的同名),确认无误即可;是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来,并把多出来的那条新档案删掉。
+22 -13
View File
@@ -32,11 +32,11 @@ deploy\build.bat
| 产物 | 类型 | 说明 |
| --- | --- | --- |
| `dist\scoreboard.exe` | 服务端 | 前端已通过内嵌方式打进可执行文件,单独运行也能提供全部页面 |
| `dist\console\Scoreboard Console-0.1.1-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 |
| `dist\console\Scoreboard Console-0.1.1-x64.zip` | 免安装包 | 解压即用 |
| `dist\console\Scoreboard Console-<版本>-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 |
| `dist\console\Scoreboard Console-<版本>-x64.zip` | 免安装包 | 解压即用 |
| `dist\console\win-unpacked\` | 目录 | 未压缩的运行目录,便于现场调试 |
> 版本号来自仓库根的 `VERSION` 文件:构建时由 `deploy\build.bat` 注入服务端二进制(`-ldflags -X main.version`,运行期不再读任何文件),并同步到 `console\package.json` 决定安装包/免安装包的文件名。**当前版本 `dev_0.1.1`**,在管理端「关于」页与 `GET /api/health` 里都能看到(没走构建脚本直接 `go build` 时兜底显示 `dev`,不编造版本号)。升级版本只改 `VERSION` 一处;以 `dist\console\` 下的实际文件名为准。
> 版本号来自仓库根的 `VERSION` 文件:构建时由 `deploy\build.bat` 注入服务端二进制(`-ldflags -X main.version`,运行期不再读任何文件),并同步到 `console\package.json` 决定安装包/免安装包的文件名。**本文不写死版本号——以仓库根 `VERSION` 与 `dist\console\` 下的实际文件名为准**,运行期可在管理端「关于」页与 `GET /api/health` 里看到(没走构建脚本直接 `go build` 时兜底显示 `dev`,不编造版本号)。升级版本只改 `VERSION` 一处。
>
> 目标机器**不需要** Go / Node / pnpm,也不联网;上述工具只用于构建机。
>
@@ -48,7 +48,7 @@ deploy\build.bat
### 3.1 使用安装包
1. 双击 `Scoreboard Console-0.1.1-x64.exe`。
1. 双击 `Scoreboard Console-<版本>-x64.exe`(版本以 `dist\console\` 下实际文件名为准)。
2. 按向导选择安装目录(可自定义)。
3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。
4. 双击「赛事积分榜」启动。
@@ -57,7 +57,7 @@ deploy\build.bat
### 3.2 使用免安装包
1. 把 `Scoreboard Console-0.1.1-x64.zip` 解压到任意目录(建议使用纯英文路径)。
1. 把 `Scoreboard Console-<版本>-x64.zip`(版本以 `dist\console\` 下实际文件名为准)解压到任意目录(建议使用纯英文路径)。
2. 双击目录里的 `Scoreboard_Console.exe` 启动。
### 3.3 首次启动会自动完成
@@ -111,7 +111,7 @@ deploy\build.bat
- Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行;
- **服务端日志也在数据目录下**:`<数据目录>\logs\` 里分 `operations\`(放行类)与 `interceptions\`(拦截类)两个子目录,按天 + 按大小轮转、自动清理旧文件,可用 `config.yaml` 的 `log:` 段改位置与保留份数(见第 10 节)。它在安装目录之外,覆盖安装不会丢;
- **上传资源随赛事项目走**:赛事会标、大屏背景图、单位 logo,以及「赛事信息 → 字体」里上传的**字体文件**,都在 `<数据目录>\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`)。它们是文件而不是数据库记录,**不在 `backups\` 的 `.db` 快照里**,备份 / 迁移时要连这个目录一起拷(见 8.5、8.6);
- **上传资源随赛事项目走**:赛事会标、大屏背景图、单位 logo,以及「赛事信息 → 字体」里上传的**字体文件**,都在 `<数据目录>\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`)。它们是文件而不是数据库记录,**不在 `backups\` 的 `.db` 快照里**(`.db` 快照与「一键恢复」都只动数据库,不含这些文件);备份 / 迁移时要连这个目录一起拷(见 8.5、8.6)。构建/开发机上的 `deploy\backup.bat` 会额外把每个项目的 `uploads\` 打成 `*-uploads-<时间戳>.zip`,但那份 zip 不在服务端的 20 份滚动保留里,要手工清理;
- **一次性迁移**:如果新数据目录还是空的,而旧的 `<安装目录>\data` 里有数据,Console 会在启动时把旧目录**整体改名**过去(同盘很快),日志里会写「已把旧数据从 X 迁移到 Y」。任何失败(跨盘、权限不足等)都只记日志,旧数据原地保留,不会被删;
- 如果两个目录里**都有数据**,Console 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份;
- 迁移只在安装包版本(打包后)执行一次,`pnpm start` 之类的开发运行不会碰仓库里的 `data\`;
@@ -140,7 +140,7 @@ deploy\build.bat
- 某一处建不出来或不可写(权限、杀毒软件拦截等)时**只记日志并回落到安装目录里那份**,绝不因此启动失败;代价是那份改动仍会在覆盖安装时丢失,日志里会明确写出来;
- 开发运行(`pnpm start`)不接管这三项,仍用仓库里的 `console\config.json`、`config.yaml`、`rules\`;
- 不用 Console、直接双击 `scoreboard.exe` 时,服务端仍按工作目录下的 `config.yaml` 读配置,**不受这套保护**;
- 上传的图片与字体不在这三项里,位置见 3.4:它们随赛事项目存放在数据目录的 `event_projs\<项目名>\uploads\`(字体在 `uploads\fonts\`),同样在安装目录之外,覆盖安装不会动它们;**但它们不在 `backups\` 的数据库快照里**,备份 / 迁移时要连这个目录一起拷(见 8.6)。
- 上传的图片与字体不在这三项里,位置见 3.4:它们随赛事项目存放在数据目录的 `event_projs\<项目名>\uploads\`(字体在 `uploads\fonts\`),同样在安装目录之外,覆盖安装不会动它们;**但它们不在 `backups\` 的数据库快照里**(「一键恢复」也只换数据库文件,不会带回图片),备份 / 迁移时要连这个目录一起拷(见 8.6)。
> 从老版本覆盖升级上来的第一次启动:安装目录里那三份在覆盖安装时**就已经被旧卸载器删过一遍**(新装进去的是新版模板),Console 只能把模板复制到用户目录,升级前改在安装目录里的配置需要重做一遍。**从本版本起**(也就是改动落在上表位置之后),配置与规则才会跨升级保留。
@@ -406,7 +406,7 @@ http://<主机IP>:19540/display
### 8.1 备份目录
所有备份都在**数据目录**下的 `backups\` 里,文件名为 `.db` 快照。默认数据目录是 `%LOCALAPPDATA%\ScoreboardConsole\data`,所以备份在:
所有备份都在**数据目录**下的 `backups\` 里,主体是 `.db` 快照;用 `deploy\backup.bat` 时还会多出 `*-uploads-*.zip`(上传图片与字体)。默认数据目录是 `%LOCALAPPDATA%\ScoreboardConsole\data`,所以备份在:
```
%LOCALAPPDATA%\ScoreboardConsole\data\backups\
@@ -421,18 +421,27 @@ http://<主机IP>:19540/display
| 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.db` |
| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 命令行 | 在安装目录执行 `scoreboard.exe backup -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`) |
| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`);**额外**把每个赛事项目的 `uploads\`(图片 + 上传字体)打成 `<项目名>-uploads-<时间戳>.zip` |
| 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` |
- 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`;
- 自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算,超出的旧备份自动删除;
- ⚠️ **`*-uploads-*.zip` 不在 20 份滚动保留范围内**(滚动只认 `.db`):它不会被自动清理,需要手工删旧文件(建议每个项目至少留最新一份)。`deploy\backup.bat` 不归档日志(`logs\`),排查材料要单独拷;空的项目 `uploads\` 会跳过,不产空包;
- 同一时刻只允许一个备份在进行,自动备份与手动、发布前备份不会互相踩;
- `deploy\backup.bat` 会自动用和 Console 相同的配置与数据目录(配置优先 `%LOCALAPPDATA%\ScoreboardConsole\config.yaml`、数据优先 `%LOCALAPPDATA%\ScoreboardConsole\data`,即用户数据目录里那两份;都不存在时回退到脚本上级目录 `..\config.yaml` 与 `..\data`),并分别作为 `-config` / `-data` 传给服务端,所以从哪里运行都不会备份到别的库;
- 现场建议:开幕前、每次发布高峰前、每天结束时各手动备份一次(自动备份只是兜底)。
### 8.3 如何恢复
服务端目前**没有一键恢复接口**,恢复需停机手工执行:
**首选:管理端「一键恢复」(仅管理员)。** 到「系统设置 → 备份」,在备份列表里找到要退回到的那一份(列表会显示时间、原因、大小;`pre-restore-…` 是恢复前自动留的安全副本),点该行的**「恢复」**:
1. 先过危险确认框(会写明要覆盖的是系统库还是赛事库、会丢弃备份时间点之后的改动);
2. 服务端先**给当前库拍一份「恢复前安全副本」**(`pre-restore-<库>-<时间戳>.db`,不参与 20 份滚动清理,选错时间点还能用它退回),再替换库文件,然后**优雅退出并由 Console 自动拉起**;
3. 页面显示「正在重启服务端…」并自动等待刷新(最多 60 秒;超时就打开服务管理窗口确认状态后手动刷新);
4. **恢复是覆盖式的**:备份时间点之后的所有改动都会丢。恢复系统库还会连**账号与会话**一起换掉,重启后需要用那个时间点的账号重新登录;
5. ⚠️ **恢复不会带回上传的图片与字体**(它们在 `uploads\` 里是文件,不在 `.db` 内);需要一起回退时见 8.6。
**没有 Console 或恢复不了时的兜底:停机手工替换文件。**
1. 在 Console 里**停止服务**,避免恢复过程中继续写入;
2. 把当前的 `<数据目录>\event_projs\<项目名>\event.db` 改名留档,确认恢复失败还能退回;
@@ -442,7 +451,7 @@ http://<主机IP>:19540/display
> `<数据目录>\system.db` 保存账号与会话信息,一般不需要回退;如需一并回退,替换它的方法与 `event.db` 相同(同时删除 `system.db-wal` / `system.db-shm`)。
>
> 恢复会丢弃备份时间点之后的所有改动。现场若还有备机,优先切换备机比原地恢复更快。
> 现场若还有备机,优先切换备机比原地恢复更快。
### 8.4 整机迁移
@@ -496,7 +505,7 @@ http://<主机IP>:19540/display
- 拷到**别的路径**:先建好目标目录并放入数据,再在用户配置 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console;
4. 恢复完成后在管理端核对赛事、账号、上传图片与字体是否齐全。
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标/单位 logo **与自定义字体**在 `event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),不拷这个目录会丢这些文件,大屏上表现为图片不显示、字体回落成默认字体。
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标/单位 logo **与自定义字体**在 `event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),不拷这个目录会丢这些文件,大屏上表现为图片不显示、字体回落成默认字体。`deploy\backup.bat` 会自动把每个项目的 `uploads\` 打成 `*-uploads-*.zip` 放在 `backups\` 里(见 8.2),可以用它替代手工拷贝——但那个 zip **不参与 20 份滚动清理**,解压后请把项目目录里的 `uploads\` 覆盖回去,并定期手工删旧的 zip。
### 8.7 基础数据包导出 / 导入(单位 / 大项 / 小项 / 组别)
@@ -509,7 +518,7 @@ http://<主机IP>:19540/display
- **按编码匹配,存在就更新、不存在就新建;绝不删除**:文件里没有的行不会被删,也不会被停用;
- **两步走**:先「预览差异」(新增 / 更新 / 无变化 / 错误 + 逐行变化,这一步不写库),确认后再点「**确认写入**」;**有错的行会跳过,合法的行照常写入**,行级错误带**真实 Excel 行号**(如 `单位 第 3 行:单位编码必填`);
- **列不在表里 = 不动该字段**;**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列按「是」,**排序留空保持原值**;
- **列不在表里 = 不动该字段**(**布尔列也一样**:缺整列不等于「取默认值」,否则把「启用」列删掉再导回,会把所有停用的单位/大项静默改成启用);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写);
- 表头行在第 4 行(系统导出件,上面是标题与副标题)或第 1 行(手工整理的表)都能识别;枚举列要填英文规范值(如 `individual` / `time` / `published`),布尔列填「是 / 否」;
- 被「彻底删除」的单位不在导出件里,导入也**不会**把它翻出来(仍是隐藏状态);
- **这个包只含基础数据**,不含成绩、报名与规则;整场赛事的搬迁仍用赛事项目包(见 8.5)。赛前导出一份留底,现场改乱了可以导回对照。
+3
View File
@@ -13,4 +13,7 @@ var (
ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用")
ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由")
ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作")
// ErrPointsOverrideUnsupported:成绩上的 pointsOverride 不被引擎读取。
// 与其静默忽略(设置成功但毫无效果),不如显式报错;加减分请走人工积分调整。
ErrPointsOverrideUnsupported = errors.New("domain: 成绩不接受 pointsOverride,请改用人工积分调整")
)
+1 -2
View File
@@ -79,7 +79,6 @@ type participant struct {
status ResultStatus
entryStatus EntryStatus
state ResultState
shares []TeamShare
}
func fixture(ps []participant) ([]Entry, []Result) {
@@ -89,7 +88,7 @@ func fixture(ps []participant) ([]Entry, []Result) {
entries = append(entries, Entry{
ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID,
AthleteID: p.athleteID, AthleteName: p.name,
Status: p.entryStatus, TeamShares: p.shares,
Status: p.entryStatus,
})
state := p.state
if state == "" {
-33
View File
@@ -278,36 +278,3 @@ func TestMatrix15_RecordBonusSum(t *testing.T) {
t.Fatalf("sum 模式 = %+v,期望 15 分一条", got)
}
}
// case 16:一条成绩归属两个单位(联队)→ 按配置拆分或整体归属。
func TestMatrix16_UnionEntry(t *testing.T) {
ps := []participant{{
entryID: 1, athleteID: 101, teamID: 1001, name: "联队选手", value: 1,
shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}},
}}
entries, results := fixture(ps)
split := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{UnionAttribution: UnionSplit},
})
byTeam := pointsByTeam(split.Ledger)
if !nearlyEqual(byTeam[1001], 6) || !nearlyEqual(byTeam[1002], 4) {
t.Fatalf("拆分归属 = %v,期望 1001:6 / 1002:4", byTeam)
}
whole := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{UnionAttribution: UnionWhole},
})
byTeam = pointsByTeam(whole.Ledger)
if !nearlyEqual(byTeam[1001], 10) || byTeam[1002] != 0 {
t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam)
}
}
+14 -12
View File
@@ -146,19 +146,21 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if athleteID == 0 {
athleteID = e.AthleteID
}
for _, s := range e.resolvedShares(in.Options.UnionAttribution) {
out = append(out, LedgerEntry{
TeamID: s.TeamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: e.ID,
SourceType: SourceRecordBonus,
SourceID: e.ID,
Points: b.Points * s.Weight,
CountToTeam: true,
})
teamID := e.primaryTeamID()
if teamID == 0 {
continue
}
out = append(out, LedgerEntry{
TeamID: teamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: e.ID,
SourceType: SourceRecordBonus,
SourceID: e.ID,
Points: b.Points,
CountToTeam: true,
})
}
return out, nil
}
-30
View File
@@ -162,36 +162,6 @@ func TestRecordBonusLedger(t *testing.T) {
t.Fatal("未产生加分流水")
}
})
t.Run("联队按份额拆分加分", func(t *testing.T) {
ps := []participant{{
entryID: 1, athleteID: 101, teamID: 1001, value: 1,
shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}},
}}
e2, r2 := fixture(ps)
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: e2, Results: r2,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
Options: ScoreOptions{UnionAttribution: UnionSplit},
})
var bonus float64
for _, e := range es.Ledger {
if e.SourceType == SourceRecordBonus && e.TeamID == 1002 {
bonus += e.Points
}
}
if !nearlyEqual(bonus, 0.8) {
t.Fatalf("单位1002 的加分 = %v,期望 2 × 0.4 = 0.8", bonus)
}
})
}
// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
+19 -37
View File
@@ -11,9 +11,6 @@ const rankEpsilon = 1e-9
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
type ScoreOptions struct {
// UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。
// 未实现:生产链路没有赋值入口,联队份额也无处加载(见 UnionAttribution 注释)。
UnionAttribution UnionAttribution
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
IncludeDrafts bool
}
@@ -38,7 +35,9 @@ type EventScore struct {
}
// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。
// Points 尚未按联队份额拆分、也未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
// Points 尚未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
// Shares 是该名次的归属单位;当前只有「整体归主单位」一种口径(联队未实现),
// 保留份额结构是因为导出 / 实时快照等消费方按份额遍历。
type RankedResult struct {
EntryID int64
ResultID int64
@@ -61,6 +60,13 @@ type RankedResult struct {
// 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。
// 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。
func ScoreEvent(in EventScoreInput) (EventScore, error) {
// 引擎不读取 pointsOverride,静默忽略等于「设置成功但毫无效果」;
// 显式报错把问题暴露在计分环节,写入侧另有 400 拦截,两道都不放行。
for _, r := range in.Results {
if r.PointsOverride != nil {
return EventScore{}, fmt.Errorf("%w: 成绩 %d(报名项 %d)", ErrPointsOverrideUnsupported, r.ID, r.EntryID)
}
}
var out EventScore
rankOnly := len(in.Channels) == 0
// 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。
@@ -355,7 +361,7 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
AthleteID: c.entry.AthleteID,
AthleteName: c.entry.AthleteName,
TeamID: c.entry.primaryTeamID(),
Shares: c.entry.resolvedShares(in.Options.UnionAttribution),
Shares: wholeShare(c.entry.primaryTeamID()),
Rank: rank,
Points: points,
Status: c.result.Status,
@@ -462,11 +468,11 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
}
if ch.Scope == ScopeTeam {
rr.TeamID = c.subject
rr.Shares = []TeamShare{{TeamID: c.subject, Weight: 1}}
rr.Shares = wholeShare(c.subject)
} else {
rr.AthleteID = c.subject
rr.AthleteName = c.entry.AthleteName
rr.Shares = c.entry.resolvedShares(in.Options.UnionAttribution)
rr.Shares = wholeShare(c.entry.primaryTeamID())
}
v := c.value
rr.ResultValue = &v
@@ -507,8 +513,8 @@ func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
return *r.ResultValue, true
}
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位,
// 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位;
// 0 分不产生流水,避免流水表被零分噪声撑大。
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry {
var out []LedgerEntry
@@ -550,34 +556,10 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
return out
}
// resolvedShares 解析报名项的归属:默认整体归主单位,联队模式才按份额拆分。
func (e Entry) resolvedShares(mode UnionAttribution) []TeamShare {
shares := e.teamShares()
if len(shares) == 0 {
// wholeShare 构造「主单位独占 100%」的归属份额。联队拆分未实现,这里只产出这一种口径。
func wholeShare(teamID int64) []TeamShare {
if teamID == 0 {
return nil
}
if mode != UnionSplit {
return []TeamShare{{TeamID: e.primaryTeamID(), Weight: 1}}
}
return normalizeShares(shares)
}
// normalizeShares 把份额归一化到总和 1,避免配置里权重不标准导致总分被放大。
func normalizeShares(shares []TeamShare) []TeamShare {
var sum float64
for _, s := range shares {
sum += s.Weight
}
out := make([]TeamShare, len(shares))
if sum <= 0 {
w := 1 / float64(len(shares))
for i, s := range shares {
out[i] = TeamShare{TeamID: s.TeamID, Weight: w}
}
return out
}
for i, s := range shares {
out[i] = TeamShare{TeamID: s.TeamID, Weight: s.Weight / sum}
}
return out
return []TeamShare{{TeamID: teamID, Weight: 1}}
}
+14 -44
View File
@@ -710,51 +710,21 @@ func TestOneAthleteMultipleEvents(t *testing.T) {
}
}
// TestUnionSharesNormalization 覆盖联队份额的归一化与退化情形。
func TestUnionSharesNormalization(t *testing.T) {
score := func(shares []TeamShare, mode UnionAttribution) map[int64]float64 {
entries, results := fixture([]participant{{
entryID: 1, athleteID: 1, teamID: 1001, value: 1, shares: shares,
}})
return pointsByTeam(mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{UnionAttribution: mode},
}).Ledger)
}
// TestScoreEventRejectsPointsOverride:引擎不读 points_override,静默忽略就是
// 「设置成功但毫无效果」;带它的成绩必须显式报错,不能照常算出别的分。
func TestScoreEventRejectsPointsOverride(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, athleteID: 1, teamID: 1001, value: 1}})
override := 99.0
results[0].PointsOverride = &override
cases := []struct {
name string
shares []TeamShare
mode UnionAttribution
want map[int64]float64
}{
{"份额 3:2 拆分为 6:4", []TeamShare{{1001, 3}, {1002, 2}}, UnionSplit, map[int64]float64{1001: 6, 1002: 4}},
{"份额 1:1 平分为 5:5", []TeamShare{{1001, 1}, {1002, 1}}, UnionSplit, map[int64]float64{1001: 5, 1002: 5}},
{"权重为 0 的份额被丢弃", []TeamShare{{1001, 0}, {1002, 1}}, UnionSplit, map[int64]float64{1002: 10}},
{"份额全非法时退回主单位", []TeamShare{{1001, 0}, {1002, 0}}, UnionSplit, map[int64]float64{1001: 10}},
{"无份额时归主单位", nil, UnionSplit, map[int64]float64{1001: 10}},
{"整体归属只记主单位", []TeamShare{{1001, 3}, {1002, 2}}, UnionWhole, map[int64]float64{1001: 10}},
{"归属口径空值等同整体", []TeamShare{{1001, 3}, {1002, 2}}, "", map[int64]float64{1001: 10}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := score(tc.shares, tc.mode)
for id, want := range tc.want {
if !nearlyEqual(got[id], want) {
t.Fatalf("单位 %d 得分 = %v,期望 %v(全部 %v)", id, got[id], want, got)
}
}
for id, p := range got {
if p != 0 {
if _, ok := tc.want[id]; !ok {
t.Fatalf("单位 %d 不应得分,实际 %v", id, p)
}
}
}
})
_, err := ScoreEvent(EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if !errors.Is(err, ErrPointsOverrideUnsupported) {
t.Fatalf("带 pointsOverride 的成绩应报 ErrPointsOverrideUnsupported,实际 %v", err)
}
}
+2 -2
View File
@@ -270,8 +270,8 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
return out
}
// teamMedals 统计团体奖牌榜。联队拆分时两个单位各记一枚:
// 联合组队拿到的名次对每个成员单位都是「本单位取得的名次」。
// teamMedals 统计团体奖牌榜。按 Shares 逐份额记牌:当前只有「主单位独占 100%」一种口径,
// 将来若支持联队拆分,每个成员单位都会各记一枚。
func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
+3 -45
View File
@@ -137,17 +137,6 @@ const (
SourceAdjustment SourceType = "adjustment"
)
// UnionAttribution 联队(一条成绩归属多个单位)的归属口径。
// 未实现:库中没有保存联队份额的表,加载器也不会填充 Entry.TeamShares,
// ScoreOptions.UnionAttribution 在生产链路里没有任何赋值入口;
// 规则包校验也不接受相关配置。这里只保留数据结构,本期不启用(devdocs 01 §115)。
type UnionAttribution string
const (
UnionWhole UnionAttribution = "whole" // 整体记给主单位
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分(未实现)
)
// AdjustmentCategory 人工调整类别。
type AdjustmentCategory string
@@ -268,8 +257,8 @@ type ScoringProfile struct {
Rules []ScoringRule `json:"rules"`
}
// TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。
// 未实现:没有建表、没有 loader 填充、也没有写入入口(见 UnionAttribution 注释)。
// TeamShare 是一条成绩的归属单位份额。联队拆分未实现,引擎只产出「主单位独占 100%」;
// 保留结构是因为导出 / 实时快照等消费方按份额遍历 RankedResult.Shares。
type TeamShare struct {
TeamID int64 `json:"teamId"`
Weight float64 `json:"weight"`
@@ -284,7 +273,6 @@ type Entry struct {
AthleteID int64 `json:"athleteId,omitempty"`
AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜
MemberNames []string `json:"memberNames,omitempty"`
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID(联队未实现,见 UnionAttribution)
Bib string `json:"bib,omitempty"`
Seed string `json:"seed,omitempty"`
Status EntryStatus `json:"status"`
@@ -503,37 +491,7 @@ func (s ResultStatus) rankable() bool {
return s == "" || s == ResultStatusOK
}
// teamShares 返回报名项的归属份额;未配联队份额时就是「主单位独占 100%」。
// 份额全部非法(权重 <= 0)时退回主单位,宁可按主单位记分也不要静默丢分。
func (e Entry) teamShares() []TeamShare {
if len(e.TeamShares) == 0 {
if e.TeamID == 0 {
return nil
}
return []TeamShare{{TeamID: e.TeamID, Weight: 1}}
}
out := make([]TeamShare, 0, len(e.TeamShares))
for _, s := range e.TeamShares {
if s.TeamID != 0 && s.Weight > 0 {
out = append(out, s)
}
}
if len(out) == 0 {
if id := e.primaryTeamID(); id != 0 {
return []TeamShare{{TeamID: id, Weight: 1}}
}
return nil
}
return out
}
// primaryTeamID 是「整体归属」时接收全部分数的单位。
func (e Entry) primaryTeamID() int64 {
if e.TeamID != 0 {
return e.TeamID
}
if len(e.TeamShares) > 0 {
return e.TeamShares[0].TeamID
}
return 0
return e.TeamID
}
+6 -39
View File
@@ -80,40 +80,10 @@ func TestProfilePointsAt(t *testing.T) {
}
}
func TestEntryTeamShares(t *testing.T) {
cases := []struct {
name string
entry Entry
want []TeamShare
}{
{"无份额归主单位", Entry{TeamID: 1}, []TeamShare{{1, 1}}},
{"无单位也无份额", Entry{}, nil},
{"保留正权重份额", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 3}, {2, 2}}}, []TeamShare{{1, 3}, {2, 2}}},
{"过滤非法份额", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 0}, {2, 1}}}, []TeamShare{{2, 1}}},
{"份额全非法退回主单位", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 0}, {2, -1}}}, []TeamShare{{1, 1}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.entry.teamShares()
if len(got) != len(tc.want) {
t.Fatalf("份额 = %+v,期望 %+v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("份额 = %+v,期望 %+v", got, tc.want)
}
}
})
}
}
func TestPrimaryTeamID(t *testing.T) {
if got := (Entry{TeamID: 7}).primaryTeamID(); got != 7 {
t.Errorf("主单位 = %d,期望 7", got)
}
if got := (Entry{TeamShares: []TeamShare{{9, 1}}}).primaryTeamID(); got != 9 {
t.Errorf("无 TeamID 时取首个份额 = %d,期望 9", got)
}
if got := (Entry{}).primaryTeamID(); got != 0 {
t.Errorf("无归属时 = %d,期望 0", got)
}
@@ -154,15 +124,12 @@ func TestRankableStatuses(t *testing.T) {
}
}
func TestNormalizeShares(t *testing.T) {
got := normalizeShares([]TeamShare{{1, 3}, {2, 2}})
if !nearlyEqual(got[0].Weight, 0.6) || !nearlyEqual(got[1].Weight, 0.4) {
t.Fatalf("归一化 = %+v,期望 0.6/0.4", got)
func TestWholeShare(t *testing.T) {
if got := wholeShare(0); got != nil {
t.Fatalf("无单位时应无份额,得到 %+v", got)
}
got = normalizeShares([]TeamShare{{1, 1}, {2, 1}, {3, 1}})
for _, s := range got {
if !nearlyEqual(s.Weight, 1.0/3) {
t.Fatalf("平均拆分 = %+v,期望各 1/3", got)
}
got := wholeShare(9)
if len(got) != 1 || got[0].TeamID != 9 || !nearlyEqual(got[0].Weight, 1) {
t.Fatalf("主单位独占份额 = %+v,期望 {9, 1}", got)
}
}
+36 -9
View File
@@ -35,14 +35,41 @@ type BackupOptions struct {
// BackupInfo 是一份备份的描述。
type BackupInfo struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Reason string `json:"reason"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Reason string `json:"reason"`
// Kind 是这份备份的性质:系统库备份 / 赛事库备份 / 恢复前安全副本(见 KindSystem 等)。
// 名字认不出来(老版本的 db- 前缀)时为空串,前端据此不给恢复入口。
Kind string `json:"kind"`
// DBKind 是这份备份对应的库(system / event);安全副本也一定属于某一份库。
// 恢复请求要的就是它:性质是 safety,但覆盖的仍是它对应的那份库。
DBKind string `json:"dbKind"`
CreatedAt time.Time `json:"createdAt"`
}
// 备份文件的性质,同时用在 BackupInfo.Kind 与列表接口上。
const (
// KindSystem 是系统库(账号 / 会话)的普通备份。
KindSystem = "system"
// KindEvent 是赛事库(赛程 / 成绩)的普通备份。
KindEvent = "event"
// KindSafety 是恢复前自动留存的安全副本。
KindSafety = "safety"
)
// backupInfoFor 用文件名补全一份备份的性质字段;名字认不出来时两个字段都留空。
func backupInfoFor(info BackupInfo) BackupInfo {
kind, dbKind, ok := KindOfBackupName(info.Name)
if !ok {
return info
}
info.Kind = kind
info.DBKind = string(dbKind)
return info
}
// Backup 用 VACUUM INTO 生成一致性快照,并按 Keep 滚动清理旧备份。
// VACUUM INTO 由 SQLite 自己保证快照一致,服务运行中也能安全备份。
func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
@@ -81,14 +108,14 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
// 文件名里的时间戳就是备份时间,文件时间与之一致,排序不会错乱。
_ = os.Chtimes(path, now, now)
info := BackupInfo{
info := backupInfoFor(BackupInfo{
Path: path,
Name: filepath.Base(path),
Size: size,
SHA256: sum,
Reason: reason,
CreatedAt: now,
}
})
if !opts.SkipPrune {
if _, err := PruneBackups(dir, prefix, opts.Keep); err != nil {
return info, err
@@ -237,13 +264,13 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
if err != nil {
return nil, fmt.Errorf("export: 读取备份信息 %s: %w", e.Name(), err)
}
out = append(out, BackupInfo{
out = append(out, backupInfoFor(BackupInfo{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
Size: info.Size(),
Reason: reasonFromName(e.Name(), prefix),
CreatedAt: info.ModTime(),
})
}))
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
+480
View File
@@ -0,0 +1,480 @@
package export
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 本文件是备份的「反向操作」:把某一份备份恢复成当前库。
//
// 与 Backup 一样按库分家:系统库(账号 / 会话)与赛事库(赛程 / 成绩)是两份文件、
// 两套备份,恢复必须指名道姓,绝不接受「看着像」的猜测——把一份库盖到另一份上,
// 现场只会表现为「账号全没了」或「名单空了」,很难倒查。
// RestoreKind 区分要恢复的是哪一个库。
type RestoreKind string
const (
// RestoreKindSystem 是系统库(<数据目录>/system.db,账号与会话)。
RestoreKindSystem RestoreKind = "system"
// RestoreKindEvent 是赛事库(<数据目录>/event_projs/<项目>/event.db,赛程与成绩)。
RestoreKindEvent RestoreKind = "event"
)
// ErrInvalidBackup 表示这份文件不能拿来恢复:读不到、不是 SQLite 库、完整性检查不过、
// 缺关键表,或者它根本是另一个库的备份。恢复前必须先把它挡在门外——
// 盖上去等于用好库换坏库,而坏的那份还在备份目录里,现场会以为数据没救。
var ErrInvalidBackup = errors.New("export: 备份文件不可用")
// ParseRestoreKind 解析请求里的 kind;只认 system / event。
func ParseRestoreKind(raw string) (RestoreKind, bool) {
switch RestoreKind(strings.TrimSpace(raw)) {
case RestoreKindSystem:
return RestoreKindSystem, true
case RestoreKindEvent:
return RestoreKindEvent, true
default:
return "", false
}
}
// Label 是给人看的中文库名,用于提示与审计。
func (k RestoreKind) Label() string {
switch k {
case RestoreKindSystem:
return "系统库"
case RestoreKindEvent:
return "赛事库"
default:
return string(k)
}
}
// safetyPrefix 是「恢复前安全副本」的文件名前缀。
// 它故意不叫 system- / event-,因为那种名字会被 PruneBackups 当成普通备份,
// 在下一轮滚动清理里删掉——安全副本是恢复失败时的唯一退路,不能被自动清理。
func (k RestoreKind) safetyPrefix() string { return "pre-restore-" + string(k) }
// KindOfBackupName 从备份文件名反推它的性质与对应的库。
//
// 认两种形态:普通备份 <库>-<原因>-<时间戳>.db,安全副本 pre-restore-<库>-<时间戳>.db;
// 认不出来的名字(例如老版本的 db- 前缀)一律不认——恢复请求的文件名只允许来自备份目录,
// 含糊的名字不进恢复流程。返回的 kind 用于显示(system / event / safety),
// dbKind 是它对应的库——恢复要覆盖的是这一份。
func KindOfBackupName(name string) (kind string, dbKind RestoreKind, ok bool) {
base := strings.ToLower(filepath.Base(strings.TrimSpace(name)))
if !strings.HasSuffix(base, ".db") {
return "", "", false
}
stem := strings.TrimSuffix(base, ".db")
// 先判安全副本(更长、更具体的前缀),否则 pre-restore-event-… 会被误判成普通备份。
for _, k := range []RestoreKind{RestoreKindSystem, RestoreKindEvent} {
if strings.HasPrefix(stem, k.safetyPrefix()+"-") {
return KindSafety, k, true
}
}
for _, k := range []RestoreKind{RestoreKindSystem, RestoreKindEvent} {
if strings.HasPrefix(stem, string(k)+"-") {
return string(k), k, true
}
}
return "", "", false
}
// restoreKeyTables 是恢复后必须存在的关键表:备份里缺了它们,就算文件能打开也不能恢复。
var restoreKeyTables = map[RestoreKind][]string{
RestoreKindSystem: {"users", "sessions", "settings"},
RestoreKindEvent: {"settings", "teams", "athletes", "events", "results", "audit_logs"},
}
// restoreForeignTables 是「另一个库」的标志表。
// settings 两边都有、不能当标志;命中这里的表说明文件根本不是这个库的备份,必须拒绝。
var restoreForeignTables = map[RestoreKind][]string{
RestoreKindSystem: {"teams", "athletes"},
RestoreKindEvent: {"users", "sessions"},
}
// ValidateBackup 校验一份备份文件能不能拿来恢复:只读打开 + integrity_check + 关键表检查。
// 任何一步不过都返回 ErrInvalidBackup,调用方据此在动当前库之前就把请求拒掉。
func ValidateBackup(path string, kind RestoreKind) error {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return fmt.Errorf("%w:读不到文件 %s", ErrInvalidBackup, filepath.Base(path))
}
if info.Size() == 0 {
return fmt.Errorf("%w:文件为空", ErrInvalidBackup)
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return fmt.Errorf("%w:%v", ErrInvalidBackup, err)
}
defer func() { _ = db.Close() }()
db.SetMaxOpenConns(1)
var check string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&check); err != nil {
// 截断 / 写垃圾的文件多半在这里现形("file is not a database")。
return fmt.Errorf("%w:完整性检查无法执行(%v),文件可能已损坏", ErrInvalidBackup, err)
}
if !strings.EqualFold(strings.TrimSpace(check), "ok") {
return fmt.Errorf("%w:完整性检查未通过(%s)", ErrInvalidBackup, strings.TrimSpace(check))
}
tables, err := tableNames(db)
if err != nil {
return fmt.Errorf("%w:%v", ErrInvalidBackup, err)
}
for _, table := range restoreKeyTables[kind] {
if !tables[table] {
return fmt.Errorf("%w:缺少关键表 %s,不是%s的完整备份", ErrInvalidBackup, table, kind.Label())
}
}
for _, table := range restoreForeignTables[kind] {
if tables[table] {
return fmt.Errorf("%w:它含有%s的表 %s,可能选错了库",
ErrInvalidBackup, foreignLabel(kind), table)
}
}
return nil
}
func foreignLabel(kind RestoreKind) string {
if kind == RestoreKindSystem {
return "赛事库"
}
return "系统库"
}
func tableNames(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out[name] = true
}
return out, rows.Err()
}
// SafetySnapshot 给「还活着的」当前库拍一份恢复前安全副本,返回副本信息。
//
// 用 VACUUM INTO:请求进来时服务端还在跑、可能还有别的连接在写,
// 只有 SQLite 自己的一致性快照靠得住(和普通备份同一套机制)。
// 副本用 pre-restore- 前缀,因此不参与 20 份滚动清理(见 safetyPrefix);
// 它自己那份保留策略由 PruneSafetyCopies 管(每类库最近 DefaultSafetyCopyKeep 份)。
func SafetySnapshot(db *sql.DB, dir string, kind RestoreKind, now time.Time) (BackupInfo, error) {
if db == nil {
return BackupInfo{}, ErrNoDatabase
}
if strings.TrimSpace(dir) == "" {
return BackupInfo{}, ErrNoDir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
}
now = nowOr(now)
path, err := safetyCopyPath(dir, kind, now)
if err != nil {
return BackupInfo{}, err
}
if err := vacuumInto(db, path); err != nil {
return BackupInfo{}, err
}
sum, size, err := hashFile(path)
if err != nil {
return BackupInfo{}, err
}
_ = os.Chtimes(path, now, now)
return BackupInfo{
Path: path,
Name: filepath.Base(path),
Size: size,
SHA256: sum,
Reason: "pre-restore",
Kind: KindSafety,
DBKind: string(kind),
CreatedAt: now,
}, nil
}
// DefaultSafetyCopyKeep 是每类库保留的「恢复前安全副本」份数。
// 它和普通备份的 DefaultBackupKeep 是两条独立的策略:安全副本不参与 20 份滚动清理
// (清理按 ListBackups 的口径认 <库>- 前缀,混进来会把普通备份挤掉),
// 但也必须有上限,否则每次恢复都多留一份整库文件,迟早把磁盘占满。
const DefaultSafetyCopyKeep = 5
// PruneSafetyCopies 把某类库的安全副本裁到最近 keep 份(<=0 取 DefaultSafetyCopyKeep),
// 按修改时间从旧到新删,最新的那一份永远不删;返回被删掉的文件路径。
//
// 只看 pre-restore-<库>-*.db:普通备份与其它文件一概不碰。
func PruneSafetyCopies(dir string, kind RestoreKind, keep int) ([]string, error) {
if keep <= 0 {
keep = DefaultSafetyCopyKeep
}
copies, err := safetyCopies(dir, kind)
if err != nil {
return nil, err
}
if len(copies) <= keep {
return nil, nil
}
removed := make([]string, 0, len(copies)-keep)
var errs []error
// copies 已按「旧 → 新」排好,从头删到只剩 keep 份,因此最新的那份一定留下。
for _, c := range copies[:len(copies)-keep] {
if err := os.Remove(c.Path); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("export: 删除旧安全副本 %s: %w", c.Path, err))
continue
}
removed = append(removed, c.Path)
}
return removed, errors.Join(errs...)
}
// safetyCopies 列出某类库的安全副本,按修改时间从旧到新;时间相同时按文件名排,保证顺序稳定。
func safetyCopies(dir string, kind RestoreKind) ([]BackupInfo, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("export: 读取备份目录 %s: %w", dir, err)
}
prefix := kind.safetyPrefix() + "-"
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
continue
}
if !strings.HasPrefix(strings.ToLower(e.Name()), prefix) {
continue
}
info, err := e.Info()
if err != nil {
return nil, fmt.Errorf("export: 读取安全副本信息 %s: %w", e.Name(), err)
}
out = append(out, backupInfoFor(BackupInfo{
Path: filepath.Join(dir, e.Name()),
Name: e.Name(),
Size: info.Size(),
Reason: "pre-restore",
CreatedAt: info.ModTime(),
}))
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].CreatedAt.Before(out[j].CreatedAt)
}
return out[i].Name < out[j].Name
})
return out, nil
}
// safetyCopyPath 为安全副本选一个不重名的路径(同一秒连续恢复也要各留一份)。
func safetyCopyPath(dir string, kind RestoreKind, now time.Time) (string, error) {
base := fmt.Sprintf("%s-%s", kind.safetyPrefix(), now.Format("20060102-150405"))
p := filepath.Join(dir, base+".db")
for i := 2; i <= 100; i++ {
_, err := os.Stat(p)
if os.IsNotExist(err) {
return p, nil
}
if err != nil {
return "", fmt.Errorf("export: 检查恢复前副本路径 %s: %w", p, err)
}
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
}
return "", fmt.Errorf("export: 恢复前副本文件名 %s 冲突过多,放弃重试", base)
}
// ApplyRestore 用备份文件的内容整体替换目标库文件。
//
// 调用方必须先关掉目标库的全部连接:Windows 上没法替换一个正被打开的文件,
// 而「就地改写」在用中的库文件会把库写坏(SQLite 的页缓存与 WAL 都对不上)。
// 备份文件本身不动(先复制到目标目录再改名),恢复失败时它还得是完整的;
// 临时文件建在目标目录里,保证改名不跨文件系统。
//
// 关掉连接后仍残留 -wal 说明还有别的进程握着这个库:旧库的日志套到新库上会直接写坏数据,
// 这种情况一律拒绝,宁可让操作员先把服务端停干净。
func ApplyRestore(backupPath, targetPath string) error {
if _, err := os.Stat(targetPath + "-wal"); err == nil {
return fmt.Errorf("export: %s 仍被占用(-wal 还在),请先停掉服务端再恢复", targetPath)
}
src, err := os.Open(backupPath)
if err != nil {
return fmt.Errorf("export: 打开备份 %s: %w", filepath.Base(backupPath), err)
}
defer func() { _ = src.Close() }()
dir := filepath.Dir(targetPath)
tmp, err := os.CreateTemp(dir, filepath.Base(targetPath)+".restore-*")
if err != nil {
return fmt.Errorf("export: 在 %s 创建临时文件: %w", dir, err)
}
tmpPath := tmp.Name()
discard := func() { _ = os.Remove(tmpPath) }
if _, err := io.Copy(tmp, src); err != nil {
_ = tmp.Close()
discard()
return fmt.Errorf("export: 复制备份到 %s: %w", tmpPath, err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
discard()
return fmt.Errorf("export: 落盘 %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {
discard()
return fmt.Errorf("export: 关闭 %s: %w", tmpPath, err)
}
// 沿用原库的权限位,免得恢复后的库文件突然变成 0600 / 0644 里的另一个。
if info, err := os.Stat(targetPath); err == nil {
_ = os.Chmod(tmpPath, info.Mode().Perm())
} else {
_ = os.Chmod(tmpPath, 0o644)
}
if err := os.Rename(tmpPath, targetPath); err != nil {
discard()
return fmt.Errorf("export: 替换 %s: %w", targetPath, err)
}
// -wal 已在上面确认不存在;-shm 只是共享内存索引,留着没有意义,顺手清掉。
_ = os.Remove(targetPath + "-shm")
return nil
}
// RestoreAuditRow 是要写进被恢复库的审计行;字段与 audit_logs 一一对应。
type RestoreAuditRow struct {
Action string
EntityType string
ActorID int64
IP string
RequestID string
// Detail 会被序列化成 after_json,记下这次恢复的关键事实。
Detail any
}
// RecordRestoreAudit 直接向 path 指向的库追加一条恢复审计。
//
// 不走 middleware.Auditor:审计是中间件在 handler 返回之后落库的,而恢复赛事库时
// 旧的赛事库连接已经关掉、文件也已经换掉,那一笔只会写进被替换掉的旧文件里。
// 恢复系统的动作要能留在「恢复之后还在的那份库」上,才查得到。
func RecordRestoreAudit(path string, row RestoreAuditRow) error {
if strings.TrimSpace(path) == "" {
return errors.New("export: 审计库路径为空")
}
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
if err != nil {
return fmt.Errorf("export: 打开审计库 %s: %w", path, err)
}
defer func() { _ = db.Close() }()
db.SetMaxOpenConns(1)
var actor any
if row.ActorID != 0 {
actor = row.ActorID
}
var after any
if row.Detail != nil {
raw, err := json.Marshal(row.Detail)
if err != nil {
return fmt.Errorf("export: 序列化审计详情: %w", err)
}
after = string(raw)
}
_, err = db.Exec(
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)`,
actor, row.Action, row.EntityType, after, row.IP, row.RequestID)
if err != nil {
return fmt.Errorf("export: 写入审计: %w", err)
}
return nil
}
// PendingRestoreFile 是「一次恢复正在进行」的落盘标记文件名。
const PendingRestoreFile = "pending_restore.json"
// PendingRestore 记录一次恢复请求的关键事实,在替换库文件之前先落盘。
//
// 正常路径上它在替换成功后立刻被删掉;只有进程恰好在替换前后意外死掉时才会留在数据目录里,
// 此时它是现场唯一的线索——「刚才要恢复哪份备份、覆盖谁、退回用的安全副本是哪一份」。
type PendingRestore struct {
Kind RestoreKind `json:"kind"`
Backup string `json:"backup"`
BackupName string `json:"backupName"`
Target string `json:"target"`
SafetyCopy string `json:"safetyCopy"`
Operator string `json:"operator"`
RequestedAt time.Time `json:"requestedAt"`
}
// WritePendingRestore 原子写入恢复标记(先写临时文件再改名),返回标记文件路径。
func WritePendingRestore(dataDir string, p PendingRestore) (string, error) {
if strings.TrimSpace(dataDir) == "" {
return "", errors.New("export: 数据目录为空,无法写入恢复标记")
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return "", fmt.Errorf("export: 创建数据目录 %s: %w", dataDir, err)
}
raw, err := json.MarshalIndent(p, "", " ")
if err != nil {
return "", fmt.Errorf("export: 序列化恢复标记: %w", err)
}
raw = append(raw, '\n')
tmp, err := os.CreateTemp(dataDir, PendingRestoreFile+".tmp-*")
if err != nil {
return "", fmt.Errorf("export: 创建恢复标记临时文件: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return "", fmt.Errorf("export: 写入恢复标记: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("export: 关闭恢复标记: %w", err)
}
path := filepath.Join(dataDir, PendingRestoreFile)
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("export: 落盘恢复标记: %w", err)
}
return path, nil
}
// ClearPendingRestore 删除恢复标记;标记本来就可能不存在(上一次恢复成功时已删),
// 删不到不算错误,只有删不掉(权限等)才回报。
func ClearPendingRestore(dataDir string) error {
if strings.TrimSpace(dataDir) == "" {
return nil
}
err := os.Remove(filepath.Join(dataDir, PendingRestoreFile))
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("export: 删除恢复标记: %w", err)
}
return nil
}
@@ -0,0 +1,447 @@
package export
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// openEventDBAt 与 helpers_test.go 的 openEventDB 相同,区别是路径由调用方给定:
// 恢复测试必须知道库文件在哪(要关连接、要替换文件、要读文件系统)。
func openEventDBAt(t *testing.T, path string) *sql.DB {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return db
}
func TestKindOfBackupName(t *testing.T) {
cases := []struct {
name string
kind string
dbKind RestoreKind
ok bool
}{
{"event-auto-20260911-120000.db", KindEvent, RestoreKindEvent, true},
{"event-publish-20260911-120000-2.db", KindEvent, RestoreKindEvent, true},
{"system-manual-20260911-120000.db", KindSystem, RestoreKindSystem, true},
// 安全副本也是合法备份:恢复选错时间点时,还能拿副本再恢复一次。
// 它的「性质」是 safety,但对应的库仍是 event / system。
{"pre-restore-event-20260911-120000.db", KindSafety, RestoreKindEvent, true},
{"pre-restore-system-20260911-120000.db", KindSafety, RestoreKindSystem, true},
{"EVENT-AUTO-20260911-120000.DB", KindEvent, RestoreKindEvent, true},
// 认不出来的一律不进恢复流程:含糊的名字没有理由被当成某一类库。
{"db-manual-20260911-120000.db", "", "", false},
{"event.db", "", "", false},
{"pre-restore-event.db", "", "", false},
{"team-restore.db", "", "", false},
{"event-auto-20260911-120000.txt", "", "", false},
}
for _, c := range cases {
kind, dbKind, ok := KindOfBackupName(c.name)
if ok != c.ok || kind != c.kind || dbKind != c.dbKind {
t.Errorf("KindOfBackupName(%q) = (%q, %q, %v),期望 (%q, %q, %v)",
c.name, kind, dbKind, ok, c.kind, c.dbKind, c.ok)
}
}
}
func TestParseRestoreKind(t *testing.T) {
if kind, ok := ParseRestoreKind(" event "); !ok || kind != RestoreKindEvent {
t.Errorf("ParseRestoreKind(\" event \") = (%q, %v)", kind, ok)
}
if kind, ok := ParseRestoreKind("system"); !ok || kind != RestoreKindSystem {
t.Errorf("ParseRestoreKind(system) = (%q, %v)", kind, ok)
}
for _, raw := range []string{"", "event.db", "System", "both"} {
if kind, ok := ParseRestoreKind(raw); ok {
t.Errorf("ParseRestoreKind(%q) 应拒绝,实际 = %q", raw, kind)
}
}
}
// 好备份必须放行;坏文件必须被挡在门外(恢复的全部安全前提都压在这一步上)。
func TestValidateBackup(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
eventBackup, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份赛事库: %v", err)
}
if err := ValidateBackup(eventBackup.Path, RestoreKindEvent); err != nil {
t.Fatalf("合法赛事库备份被拒: %v", err)
}
// 拿赛事库备份去恢复系统库:两份库的表完全不同,必须拒绝。
if err := ValidateBackup(eventBackup.Path, RestoreKindSystem); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("赛事库备份当成系统库备份 = %v,期望 ErrInvalidBackup", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "sysdata"))
if err != nil {
t.Fatalf("打开系统库: %v", err)
}
defer func() { _ = st.Close() }()
if err := st.Migrate(); err != nil {
t.Fatalf("迁移系统库: %v", err)
}
sysBackup, err := Backup(st.DB(), BackupOptions{Dir: dir, Prefix: "system", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份系统库: %v", err)
}
if err := ValidateBackup(sysBackup.Path, RestoreKindSystem); err != nil {
t.Fatalf("合法系统库备份被拒: %v", err)
}
if err := ValidateBackup(sysBackup.Path, RestoreKindEvent); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("系统库备份当成赛事库备份 = %v,期望 ErrInvalidBackup", err)
}
raw, err := os.ReadFile(eventBackup.Path)
if err != nil {
t.Fatalf("读取备份: %v", err)
}
cases := []struct {
name string
path string
body []byte
}{
{"截断到三分之一", filepath.Join(dir, "event-manual-cut.db"), raw[:len(raw)/3]},
{"全是垃圾字节", filepath.Join(dir, "event-manual-junk.db"), []byte(strings.Repeat("这不是数据库,只是 200 字节垃圾。", 20))},
{"空文件", filepath.Join(dir, "event-manual-empty.db"), nil},
}
for _, c := range cases {
if err := os.WriteFile(c.path, c.body, 0o644); err != nil {
t.Fatalf("写坏文件 %s: %v", c.name, err)
}
if err := ValidateBackup(c.path, RestoreKindEvent); err == nil {
t.Errorf("%s:坏备份必须被拒绝", c.name)
} else if !errors.Is(err, ErrInvalidBackup) {
t.Errorf("%s:错误类型 = %v,期望 ErrInvalidBackup", c.name, err)
}
}
if err := ValidateBackup(filepath.Join(dir, "event-manual-missing.db"), RestoreKindEvent); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("不存在的备份 = %v,期望 ErrInvalidBackup", err)
}
if err := ValidateBackup(dir, RestoreKindEvent); !errors.Is(err, ErrInvalidBackup) {
t.Errorf("目录当成备份 = %v,期望 ErrInvalidBackup", err)
}
}
// 安全副本必须是「改动之后」的现场,恢复则必须把库退回备份时刻,且备份文件本身要留着。
func TestSafetySnapshotAndApplyRestore(t *testing.T) {
target := filepath.Join(t.TempDir(), "event_projs", "default", "event.db")
db := openEventDBAt(t, target)
seedEvent(t, db) // 2 个单位
dir := t.TempDir()
backup, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if _, err := db.Exec(`INSERT INTO teams (code, name, short_name, sort_order) VALUES ('T3','第三分公司','三公司',3)`); err != nil {
t.Fatalf("备份后改数据: %v", err)
}
safety, err := SafetySnapshot(db, dir, RestoreKindEvent, time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local))
if err != nil {
t.Fatalf("拍安全副本: %v", err)
}
if !strings.HasPrefix(safety.Name, "pre-restore-event-20260911-100000") {
t.Errorf("安全副本命名 = %q,期望 pre-restore-event-<时间戳>.db", safety.Name)
}
// 列表与回执要靠这两个字段说清「这是什么、它对应哪份库」,前端不再猜文件名。
if safety.Kind != KindSafety || safety.DBKind != string(RestoreKindEvent) {
t.Errorf("安全副本 kind/dbKind = (%q, %q),期望 (%q, event)",
safety.Kind, safety.DBKind, KindSafety)
}
// 真实链路里由 handler 关连接;这里照做,才能验证「关机后替换」这套动作本身。
if err := db.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
if _, err := os.Stat(target + "-wal"); err == nil {
t.Fatalf("关闭后仍残留 -wal,恢复会被自己的占用检查挡住")
}
sdb := openSQLite(t, safety.Path)
if got := countRows(t, sdb, "teams"); got != 3 {
t.Errorf("安全副本 teams = %d,期望 3(备份之后的现场)", got)
}
_ = sdb.Close()
if err := ApplyRestore(backup.Path, target); err != nil {
t.Fatalf("替换数据库: %v", err)
}
// 恢复用的是「复制」而不是「搬走」:备份文件还得在,才能反复恢复。
if _, err := os.Stat(backup.Path); err != nil {
t.Errorf("恢复后备份文件应当保留: %v", err)
}
if _, err := os.Stat(target + "-shm"); !os.IsNotExist(err) {
t.Errorf("恢复后不应残留 -shm,err=%v", err)
}
rdb := openSQLite(t, target)
if got := countRows(t, rdb, "teams"); got != 2 {
t.Errorf("恢复后 teams = %d,期望 2(备份时刻)", got)
}
_ = rdb.Close()
if err := ValidateBackup(target, RestoreKindEvent); err != nil {
t.Errorf("恢复后的库必须自身也能通过校验: %v", err)
}
}
// 安全副本自己的保留策略:每类库只留最近 N 份,按 mtime 从旧到新删,最新的永不删;
// 普通备份与另一类库的副本一份都不许碰。
func TestPruneSafetyCopies(t *testing.T) {
dir := t.TempDir()
base := time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local)
// 7 份赛事库安全副本(mtime 依次递增),外加两份「不该被删」的干扰文件。
for i := 0; i < 7; i++ {
name := filepath.Join(dir, fmt.Sprintf("pre-restore-event-20260911-1000%02d.db", i))
if err := os.WriteFile(name, []byte("x"), 0o644); err != nil {
t.Fatalf("造安全副本: %v", err)
}
at := base.Add(time.Duration(i) * time.Minute)
if err := os.Chtimes(name, at, at); err != nil {
t.Fatalf("改时间: %v", err)
}
}
kept := filepath.Join(dir, "event-auto-20260911-090000.db")
if err := os.WriteFile(kept, []byte("x"), 0o644); err != nil {
t.Fatalf("造普通备份: %v", err)
}
other := filepath.Join(dir, "pre-restore-system-20260911-100000.db")
if err := os.WriteFile(other, []byte("x"), 0o644); err != nil {
t.Fatalf("造系统库安全副本: %v", err)
}
removed, err := PruneSafetyCopies(dir, RestoreKindEvent, DefaultSafetyCopyKeep)
if err != nil {
t.Fatalf("清理安全副本: %v", err)
}
if len(removed) != 2 {
t.Fatalf("清理份数 = %d,期望 2(7-5)", len(removed))
}
// 删掉的是最旧的两份。
for _, i := range []int{0, 1} {
gone := filepath.Join(dir, fmt.Sprintf("pre-restore-event-20260911-1000%02d.db", i))
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Errorf("最旧的 %s 应被删除,err=%v", gone, err)
}
}
// 最新的一份必须还在(无论 keep 传多少,它都不该被删)。
newest := filepath.Join(dir, "pre-restore-event-20260911-100006.db")
if _, err := os.Stat(newest); err != nil {
t.Errorf("最新的安全副本必须保留: %v", err)
}
if _, err := os.Stat(kept); err != nil {
t.Errorf("普通备份不属于安全副本,不该被动: %v", err)
}
if _, err := os.Stat(other); err != nil {
t.Errorf("另一类库的安全副本不该被动: %v", err)
}
// 份数已经等于保留数:再清一次什么都不动。
again, err := PruneSafetyCopies(dir, RestoreKindEvent, DefaultSafetyCopyKeep)
if err != nil {
t.Fatalf("再清理: %v", err)
}
if len(again) != 0 {
t.Errorf("已裁到上限时不应再删,实际删了 %v", again)
}
// keep<=0 取默认份数,而不是「一份不留」。
if removed, err := PruneSafetyCopies(dir, RestoreKindEvent, 0); err != nil || len(removed) != 0 {
t.Errorf("keep<=0 应按默认份数处理,removed=%v err=%v", removed, err)
}
// 目录不存在不是错误。
if removed, err := PruneSafetyCopies(filepath.Join(dir, "没有这个目录"), RestoreKindEvent, 5); err != nil || len(removed) != 0 {
t.Errorf("目录不存在应返回空,removed=%v err=%v", removed, err)
}
}
// 列表接口要能自描述:普通备份带 kind/dbKind,安全副本 kind=safety 但 dbKind 仍是它那份库。
func TestListBackupsReportsKind(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
base := time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local)
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true, Now: base}); err != nil {
t.Fatalf("赛事库备份: %v", err)
}
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "system", Reason: "auto", SkipPrune: true, Now: base}); err != nil {
t.Fatalf("系统库备份: %v", err)
}
if _, err := SafetySnapshot(db, dir, RestoreKindEvent, base.Add(time.Minute)); err != nil {
t.Fatalf("安全副本: %v", err)
}
list, err := ListBackups(dir, "")
if err != nil {
t.Fatalf("列出备份: %v", err)
}
want := map[string][2]string{
"event-manual-20260911-100000.db": {KindEvent, "event"},
"system-auto-20260911-100000.db": {KindSystem, "system"},
"pre-restore-event-20260911-100100.db": {KindSafety, "event"},
}
for _, item := range list {
expect, ok := want[item.Name]
if !ok {
t.Errorf("多出一份未预期的备份 %s", item.Name)
continue
}
if item.Kind != expect[0] || item.DBKind != expect[1] {
t.Errorf("%s 的 kind/dbKind = (%q, %q),期望 (%q, %q)",
item.Name, item.Kind, item.DBKind, expect[0], expect[1])
}
}
if len(list) != len(want) {
t.Errorf("备份份数 = %d,期望 %d", len(list), len(want))
}
}
// Backup 的返回值也要带性质:调用方(如 CLI / 面板)不必再从文件名推。
func TestBackupReportsKind(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
dir := t.TempDir()
info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if info.Kind != KindEvent || info.DBKind != "event" {
t.Errorf("Backup 的 kind/dbKind = (%q, %q),期望 (event, event)", info.Kind, info.DBKind)
}
// 认不出前缀的(老版本 db- 前缀)留空,前端据此不给恢复入口,而不是猜成某一类。
legacy, err := Backup(db, BackupOptions{Dir: dir, Prefix: "db", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if legacy.Kind != "" || legacy.DBKind != "" {
t.Errorf("无法识别前缀时 kind/dbKind 应为空,实际 (%q, %q)", legacy.Kind, legacy.DBKind)
}
}
// 目标库旁边还留着 -wal,说明有连接没关干净(或别的进程握着它):
// 旧库的日志套到新库上会直接写坏数据,必须拒绝而不是硬上。
func TestApplyRestoreRefusesWhileWalPresent(t *testing.T) {
target := filepath.Join(t.TempDir(), "event.db")
db := openEventDBAt(t, target)
seedEvent(t, db)
dir := t.TempDir()
backup, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "manual", SkipPrune: true})
if err != nil {
t.Fatalf("备份: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
if err := os.WriteFile(target+"-wal", []byte("stale"), 0o644); err != nil {
t.Fatalf("伪造 -wal: %v", err)
}
if err := ApplyRestore(backup.Path, target); err == nil {
t.Fatal("-wal 残留时替换必须被拒绝")
}
}
// 审计直接写进赛事库(middleware.Auditor 的落库时机在换库之后,指望不上)。
func TestRecordRestoreAudit(t *testing.T) {
target := filepath.Join(t.TempDir(), "event.db")
db := openEventDBAt(t, target)
if err := RecordRestoreAudit(target, RestoreAuditRow{
Action: "system.restore",
EntityType: "backup",
ActorID: 7,
IP: "10.0.0.9",
RequestID: "req-1",
Detail: map[string]any{"kind": "event", "file": "event-manual-x.db"},
}); err != nil {
t.Fatalf("写审计: %v", err)
}
_ = db.Close()
rdb := openSQLite(t, target)
defer func() { _ = rdb.Close() }()
var action, after, ip string
var actorID int64
if err := rdb.QueryRow(`SELECT action, after_json, ip, user_id FROM audit_logs ORDER BY id DESC LIMIT 1`).
Scan(&action, &after, &ip, &actorID); err != nil {
t.Fatalf("读审计: %v", err)
}
if action != "system.restore" || actorID != 7 || ip != "10.0.0.9" {
t.Errorf("审计行 = (%s, %d, %s),期望 (system.restore, 7, 10.0.0.9)", action, actorID, ip)
}
if !strings.Contains(after, "event-manual-x.db") {
t.Errorf("审计 after_json = %q,应含备份文件名", after)
}
}
// 恢复标记:写盘后能删掉;重复删不报错(安装目录里可能根本没有这个文件)。
func TestPendingRestoreMarker(t *testing.T) {
dataDir := t.TempDir()
path, err := WritePendingRestore(dataDir, PendingRestore{
Kind: RestoreKindEvent,
Backup: "/data/backups/event-manual-20260911-100000.db",
BackupName: "event-manual-20260911-100000.db",
Target: "/data/event_projs/default/event.db",
SafetyCopy: "/data/backups/pre-restore-event-20260911-100001.db",
Operator: "admin#1",
RequestedAt: time.Date(2026, 9, 11, 10, 0, 1, 0, time.Local),
})
if err != nil {
t.Fatalf("写恢复标记: %v", err)
}
if filepath.Base(path) != PendingRestoreFile {
t.Errorf("标记路径 = %q,期望 %q", path, PendingRestoreFile)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读恢复标记: %v", err)
}
for _, want := range []string{"pre-restore-event-20260911-100001.db", "admin#1", `"kind": "event"`} {
if !strings.Contains(string(raw), want) {
t.Errorf("标记内容缺少 %q:%s", want, raw)
}
}
if err := ClearPendingRestore(dataDir); err != nil {
t.Fatalf("删恢复标记: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("标记应被删除,err=%v", err)
}
if err := ClearPendingRestore(dataDir); err != nil {
t.Errorf("重复删除不应报错: %v", err)
}
}
-13
View File
@@ -147,19 +147,6 @@ func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord {
}
}
// ExportBaseDataXLSX 导出基础数据包到 dst。
func ExportBaseDataXLSX(db *sql.DB, dst string, opts Options) error {
wb, err := LoadBaseDataWorkbook(db, opts)
if err != nil {
return err
}
f, err := baseDataFile(wb)
if err != nil {
return err
}
return saveWorkbook(f, dst)
}
// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。
func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error {
f, err := baseDataFile(wb)
@@ -0,0 +1,86 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// action 过滤是子串模糊匹配,且与 entityType / userId 可同时组合(AND)。
func TestAuditLogsActionFilter(t *testing.T) {
env := newTestEnv(t)
adminID := env.createUser("admin", "admin-pass", "admin")
recorderID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 登录本身会写一条审计,先清干净再做确定性断言。
env.exec(t, `DELETE FROM audit_logs`)
seed := func(action, entity string, user int64) {
env.exec(t, `INSERT INTO audit_logs(action, entity_type, entity_id, user_id, ip, request_id)
VALUES (?, ?, 1, ?, '127.0.0.1', 'req')`, action, entity, user)
}
seed("team.create", "team", adminID)
seed("team.remove", "team", recorderID)
seed("result.publish", "event", adminID)
seed("auth.login", "user", recorderID)
seed("a_b", "team", adminID) // 用于验证 LIKE 的 _ 被转义成字面量
seed("axb", "team", adminID)
list := func(query string) []map[string]any {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/audit-logs"+query, nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("查询 %s 应 200,得到 %d %+v", query, status, env2.Error)
}
raw, _ := decodeData[map[string]any](t, env2)["items"].([]any)
items := make([]map[string]any, 0, len(raw))
for _, item := range raw {
items = append(items, item.(map[string]any))
}
return items
}
count := func(query string) int {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/audit-logs"+query, nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("查询 %s 应 200,得到 %d %+v", query, status, env2.Error)
}
return int(decodeData[map[string]any](t, env2)["total"].(float64))
}
// 模糊匹配:team. 命中两条,publish 命中 result.publish。
if got := count("?action=team."); got != 2 {
t.Errorf("action=team. 应命中 2 条,得到 %d", got)
}
if got := count("?action=publish"); got != 1 {
t.Errorf("action=publish 应命中 1 条,得到 %d", got)
}
// _ 按字面量匹配,不当作通配符。
if got := count("?action=a_b"); got != 1 {
t.Errorf("action=a_b 应只命中字面量 1 条,得到 %d", got)
}
// 组合过滤是 AND。
for _, q := range []struct {
query string
want int
}{
{"?action=team.&entityType=team", 2},
{fmt.Sprintf("?action=team.&userId=%d", recorderID), 1},
{fmt.Sprintf("?action=a_b&entityType=team&userId=%d", adminID), 1},
{fmt.Sprintf("?action=team.&entityType=team&userId=%d", recorderID), 1},
{"?action=ghost", 0},
} {
if got := count(q.query); got != q.want {
t.Errorf("查询 %s 应命中 %d 条,得到 %d", q.query, q.want, got)
}
}
// 返回项必须真的满足过滤条件。
for _, item := range list(fmt.Sprintf("?action=team.&entityType=team&userId=%d", recorderID)) {
if !strings.Contains(item["action"].(string), "team.") || item["entityType"] != "team" || item["userId"] != float64(recorderID) {
t.Fatalf("组合过滤返回了不匹配的记录: %+v", item)
}
}
}
@@ -0,0 +1,180 @@
package httpapi_test
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
)
// safetyCopiesIn 列出备份目录里的安全副本文件名(排序后,便于逐字比对)。
func safetyCopiesIn(t *testing.T, dir, prefix string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读目录 %s: %v", dir, err)
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
// 安全副本自己那条保留策略:每类库最近 5 份,超出的按 mtime 从旧到新删,最新那份永不删。
// 它必须和普通备份的 20 份滚动互不干扰(那份只认 <库>- 前缀)。
func TestRestorePrunesOldSafetyCopies(t *testing.T) {
env, _, dataDir := backupRestoreEnv(t)
env.seed(t)
client := env.loginAdmin(t)
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
if status != http.StatusOK {
t.Fatalf("手动备份 status=%d", status)
}
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
raw, err := os.ReadFile(eventBackup.Path)
if err != nil {
t.Fatalf("读取备份: %v", err)
}
// 预置 6 份旧安全副本(内容是真的库文件,只是 mtime 依次往前拨),
// 再加本次恢复自己生成的那一份,一共 7 份 → 应裁到 5 份、删掉最旧的两份。
backupsDir := filepath.Join(dataDir, "backups")
base := time.Now().Add(-6 * time.Hour)
seeded := make([]string, 0, 6)
for i := 0; i < 6; i++ {
name := fmt.Sprintf("pre-restore-event-20260911-0000%02d.db", i)
path := filepath.Join(backupsDir, name)
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatalf("预置安全副本: %v", err)
}
at := base.Add(time.Duration(i) * time.Minute)
if err := os.Chtimes(path, at, at); err != nil {
t.Fatalf("改 mtime: %v", err)
}
seeded = append(seeded, name)
}
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": eventBackup.Name, "kind": "event"}, true)
if status != http.StatusOK {
t.Fatalf("恢复 status=%d %+v", status, resp.Error)
}
result := decodeData[struct {
SafetyCopy string `json:"safetyCopy"`
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
Message string `json:"message"`
}](t, resp)
if result.PrunedSafetyCopies != 2 {
t.Errorf("清理份数 = %d,期望 2(7 份裁到 5 份)", result.PrunedSafetyCopies)
}
if !strings.Contains(result.Message, "已清理 2 份") {
t.Errorf("回执应说明清理了几份:%q", result.Message)
}
left := safetyCopiesIn(t, backupsDir, "pre-restore-event-")
if len(left) != 5 {
t.Fatalf("剩余安全副本 = %d 份(%v),期望 5 份", len(left), left)
}
// 最旧的两份(mtime 最靠前)被删;本次刚生成的那份一定还在。
for _, name := range seeded[:2] {
if _, err := os.Stat(filepath.Join(backupsDir, name)); !os.IsNotExist(err) {
t.Errorf("最旧的 %s 应被清理,err=%v", name, err)
}
}
if _, err := os.Stat(filepath.Join(backupsDir, result.SafetyCopy)); err != nil {
t.Errorf("本次生成的安全副本必须保留: %v", err)
}
// 普通备份不属于安全副本,一份都不该被动。
if _, err := os.Stat(eventBackup.Path); err != nil {
t.Errorf("普通备份不该被安全副本的清理删掉: %v", err)
}
// 列表里普通备份仍是 2 份(system + event);安全副本另有 5 份,各算各的。
plain := 0
for _, item := range env.listBackups(t, client) {
if item.Kind == "system" || item.Kind == "event" {
plain++
}
}
if plain != 2 {
t.Errorf("普通备份份数 = %d,期望 2(system + event)", plain)
}
}
// 安全副本也能拿来恢复:列表给出 kind=safety + dbKind=event,
// 恢复请求照 dbKind 传 kind,服务端按文件名判定覆盖哪份库——这就是「选错时间点再退回来」。
//
// 安全副本这里直接用 export.SafetySnapshot 造(与 handler 内部同一条代码路径):
// 走一次 endpoint 恢复会把测试环境里那个连接关掉、又没人重启进程,
// 第二次请求就没法真正跑到恢复逻辑;本用例要验的是「拿安全副本恢复」这一段。
func TestRestoreFromSafetyCopy(t *testing.T) {
env, eventPath, dataDir := backupRestoreEnv(t)
env.seed(t)
client := env.loginAdmin(t)
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
if status != http.StatusOK {
t.Fatalf("手动备份 status=%d", status)
}
// 先确认有普通备份(列表自描述的另一半:普通备份带 kind=dbKind=event)。
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
if eventBackup.Kind != "event" || eventBackup.DBKind != "event" {
t.Fatalf("普通赛事库备份 kind/dbKind = (%q, %q),期望 (event, event)", eventBackup.Kind, eventBackup.DBKind)
}
// 改乱:加一个单位(3 个),再给当前现场留一份安全副本。
env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
safety, err := export.SafetySnapshot(env.ev, filepath.Join(dataDir, "backups"), export.RestoreKindEvent, time.Now())
if err != nil {
t.Fatalf("拍安全副本: %v", err)
}
if safety.Kind != "safety" || safety.DBKind != "event" {
t.Fatalf("安全副本 kind/dbKind = (%q, %q),期望 (safety, event)", safety.Kind, safety.DBKind)
}
// 列表接口要能自描述:安全副本 kind=safety,但它对应的库是 event。
listed := findBackup(t, env.listBackups(t, client), "pre-restore-event-")
if listed.Name != safety.Name {
t.Fatalf("列表里的安全副本 = %q,期望刚生成的那份 %q", listed.Name, safety.Name)
}
if listed.Kind != "safety" || listed.DBKind != "event" {
t.Fatalf("安全副本的 kind/dbKind = (%q, %q),期望 (safety, event)", listed.Kind, listed.DBKind)
}
// 用安全副本退回「改乱后」的现场:kind 传列表给的 dbKind。
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": listed.Name, "kind": listed.DBKind}, true)
if status != http.StatusOK {
t.Fatalf("用安全副本恢复 status=%d %+v", status, resp.Error)
}
result := decodeData[struct {
Kind string `json:"kind"`
DBKind string `json:"dbKind"`
SafetyCopy string `json:"safetyCopy"`
File string `json:"file"`
}](t, resp)
if result.Kind != "safety" || result.DBKind != "event" || result.File != safety.Name {
t.Errorf("回执 = %+v,期望 kind=safety dbKind=event file=%s", result, safety.Name)
}
if got := countRowsAt(t, eventPath, "teams"); got != 3 {
t.Errorf("用安全副本退回后 teams = %d,期望 3(恢复前的现场)", got)
}
// 这份安全副本用完之后仍在原处(恢复是复制,不是搬走),还能再恢复一次。
if _, err := os.Stat(safety.Path); err != nil {
t.Errorf("安全副本文件应保留: %v", err)
}
// 这次恢复自己也留了安全副本,内容是恢复前的现场(3 个单位)。
if got := countRowsAt(t, filepath.Join(dataDir, "backups", result.SafetyCopy), "teams"); got != 3 {
t.Errorf("本次恢复留下的安全副本 teams = %d,期望 3(恢复前现场)", got)
}
}
@@ -0,0 +1,325 @@
package httpapi_test
import (
"database/sql"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// backupRestoreEnv 建一个「赛事项目目录已配置」的环境:
// 恢复赛事库要覆盖服务端当前打开的那份 event.db,路径取自 Options.ProjectDir。
func backupRestoreEnv(t *testing.T) (env *testEnv, eventPath, dataDir string) {
t.Helper()
env = newTestEnvWith(t, func(o *httpapi.Options) {
o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test")
eventPath = filepath.Join(o.ProjectDir, "event.db")
dataDir = o.DataDir
})
return env, eventPath, dataDir
}
// loginAdmin 建一个管理员并登录,返回带会话 Cookie 的客户端。
func (e *testEnv) loginAdmin(t *testing.T) *http.Client {
t.Helper()
e.createUser("admin", "pass-1234", "admin")
client, _ := e.client()
e.login(client, "admin", "pass-1234")
return client
}
type backupListEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind string `json:"kind"`
DBKind string `json:"dbKind"`
}
// listBackups 走管理端读备份列表(恢复请求里的文件名就来自这里)。
func (e *testEnv) listBackups(t *testing.T, client *http.Client) []backupListEntry {
t.Helper()
status, env := e.do(client, http.MethodGet, "/api/export/backups", nil, false)
if status != http.StatusOK {
t.Fatalf("读备份列表 status=%d %+v", status, env.Error)
}
return decodeData[[]backupListEntry](t, env)
}
func findBackup(t *testing.T, items []backupListEntry, prefix string) backupListEntry {
t.Helper()
for _, item := range items {
if strings.HasPrefix(item.Name, prefix) {
return item
}
}
t.Fatalf("备份列表里没有 %s 开头的文件:%+v", prefix, items)
return backupListEntry{}
}
// countRowsAt 另开只读连接数表行数:恢复会把服务端手里的连接关掉、文件换掉,
// 断言必须来自「当前磁盘上的那份库」,而不是测试环境里那个已经不作数的句柄。
func countRowsAt(t *testing.T, path, table string) int {
t.Helper()
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
t.Fatalf("打开 %s: %v", path, err)
}
defer func() { _ = db.Close() }()
var n int
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil {
t.Fatalf("统计 %s.%s: %v", path, table, err)
}
return n
}
func lastAuditAt(t *testing.T, path string) (action, after, ip string) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
t.Fatalf("打开 %s: %v", path, err)
}
defer func() { _ = db.Close() }()
if err := db.QueryRow(
`SELECT action, COALESCE(after_json, ''), COALESCE(ip, '') FROM audit_logs ORDER BY id DESC LIMIT 1`).
Scan(&action, &after, &ip); err != nil {
t.Fatalf("读审计: %v", err)
}
return action, after, ip
}
func listDir(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读目录 %s: %v", dir, err)
}
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
func findInDir(t *testing.T, dir, prefix string) string {
t.Helper()
for _, name := range listDir(t, dir) {
if strings.HasPrefix(name, prefix) {
return name
}
}
t.Fatalf("%s 下没有 %s 开头的文件:%v", dir, prefix, listDir(t, dir))
return ""
}
// 坏文件必须被挡在门外,并且当前库毫发无伤(这是恢复功能存在的全部前提)。
func TestRestoreBackupRejectsBadFileWithoutTouchingCurrentDB(t *testing.T) {
env, eventPath, dataDir := backupRestoreEnv(t)
env.seed(t) // 2 个单位
client := env.loginAdmin(t)
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
if status != http.StatusOK {
t.Fatalf("手动备份 status=%d", status)
}
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
backupsDir := filepath.Join(dataDir, "backups")
// 备份之后再加一个单位:坏文件被拒时这个单位必须还在。
env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
before := countRowsAt(t, eventPath, "teams")
if before != 3 {
t.Fatalf("改动后 teams = %d,期望 3", before)
}
// 把一份「文件名像备份」的文件截断,再请求恢复它。
truncated := filepath.Join(backupsDir, "event-manual-20260911-235959.db")
raw, err := os.ReadFile(eventBackup.Path)
if err != nil {
t.Fatalf("读取备份: %v", err)
}
if err := os.WriteFile(truncated, raw[:len(raw)/3], 0o644); err != nil {
t.Fatalf("写截断文件: %v", err)
}
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": filepath.Base(truncated), "kind": "event"}, true)
if status != http.StatusBadRequest {
t.Fatalf("坏文件恢复 status=%d,期望 400;resp=%+v", status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, "备份文件不可用") {
t.Fatalf("坏文件提示 = %+v,期望中文「备份文件不可用」", resp.Error)
}
if got := countRowsAt(t, eventPath, "teams"); got != before {
t.Errorf("坏文件被拒后 teams = %d,期望原样 %d", got, before)
}
for _, name := range listDir(t, backupsDir) {
if strings.HasPrefix(name, "pre-restore-") {
t.Errorf("坏文件在替换之前就该被拒,不该产生安全副本:%s", name)
}
}
// 文件名与 kind 对不上(赛事库备份当成系统库恢复)同样必须拒绝。
status, resp = env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": eventBackup.Name, "kind": "system"}, true)
if status != http.StatusBadRequest {
t.Fatalf("kind 不匹配 status=%d,期望 400;resp=%+v", status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, "赛事库的备份") {
t.Fatalf("kind 不匹配提示 = %+v,期望说明它是赛事库的备份", resp.Error)
}
// 目录穿越、无关文件、认不出前缀的名字都必须被拒。
for _, bad := range []string{"../../../system.db", "system-not-a-backup.txt", "db-manual-20260911-235959.db", ""} {
status, _ = env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": bad, "kind": "system"}, true)
if status != http.StatusBadRequest {
t.Errorf("非法文件名 %q status=%d,期望 400", bad, status)
}
}
if got := countRowsAt(t, eventPath, "teams"); got != before {
t.Errorf("一连串被拒的请求之后 teams = %d,期望 %d", got, before)
}
}
// 恢复成功路径:库回到备份时刻、留下恢复前安全副本、审计有记录、赛事库与系统库互不牵连。
func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) {
env, eventPath, dataDir := backupRestoreEnv(t)
env.seed(t) // 2 个单位
client := env.loginAdmin(t)
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
if status != http.StatusOK {
t.Fatalf("手动备份 status=%d", status)
}
eventBackup := findBackup(t, env.listBackups(t, client), "event-")
// 备份之后继续操作:加一个单位(模拟「改乱了」)。
env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('C', '第三分公司', '三司', 2, 1)`)
if got := countRowsAt(t, eventPath, "teams"); got != 3 {
t.Fatalf("改动后 teams = %d,期望 3", got)
}
sysUsersBefore := countRowsAt(t, filepath.Join(dataDir, "system.db"), "users")
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": eventBackup.Name, "kind": "event"}, true)
if status != http.StatusOK {
t.Fatalf("恢复 status=%d %+v", status, resp.Error)
}
result := decodeData[struct {
Kind string `json:"kind"`
DBKind string `json:"dbKind"`
File string `json:"file"`
Target string `json:"target"`
SafetyCopy string `json:"safetyCopy"`
SafetyCopyPath string `json:"safetyCopyPath"`
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
Restarting bool `json:"restarting"`
Message string `json:"message"`
}](t, resp)
if result.Kind != "event" || result.DBKind != "event" || result.File != eventBackup.Name || result.Target != eventPath {
t.Errorf("恢复回执 = %+v,期望 kind=event dbKind=event file=%s target=%s", result, eventBackup.Name, eventPath)
}
// 备份文件名里不含项目名,回执必须提醒操作员确认来源。
if !strings.Contains(result.Message, "不含来源项目") {
t.Errorf("回执应提醒「备份文件名里不含来源项目」:%q", result.Message)
}
// 测试环境没接优雅关闭:文件已经换好,只是没有进程可重启。
if result.Restarting {
t.Errorf("未接入优雅关闭时 restarting 应为 false")
}
if !strings.HasPrefix(result.SafetyCopy, "pre-restore-event-") {
t.Errorf("安全副本名 = %q,期望 pre-restore-event- 开头", result.SafetyCopy)
}
if _, err := os.Stat(result.SafetyCopyPath); err != nil {
t.Fatalf("安全副本文件应当存在: %v", err)
}
if result.Message == "" || !strings.Contains(result.Message, result.SafetyCopy) {
t.Errorf("回执提示应带上安全副本名:%q", result.Message)
}
// ① 当前库回到备份时刻:备份之后加的单位没了,原有两个还在。
if got := countRowsAt(t, eventPath, "teams"); got != 2 {
t.Errorf("恢复后 teams = %d,期望 2(备份时刻)", got)
}
// ② 恢复前安全副本保存的是「改过之后」的现场(3 个单位),随时能退回来。
if got := countRowsAt(t, result.SafetyCopyPath, "teams"); got != 3 {
t.Errorf("安全副本 teams = %d,期望 3(恢复前现场)", got)
}
// ③ 审计有记录,且带上这次恢复的关键事实。
action, after, ip := lastAuditAt(t, eventPath)
if action != "system.restore" {
t.Errorf("审计 action = %q,期望 system.restore", action)
}
for _, want := range []string{eventBackup.Name, result.SafetyCopy, "event"} {
if !strings.Contains(after, want) {
t.Errorf("审计 after = %q,缺少 %q", after, want)
}
}
if ip == "" {
t.Errorf("审计应记下来源 IP")
}
// ④ 恢复赛事库不碰系统库。
if got := countRowsAt(t, filepath.Join(dataDir, "system.db"), "users"); got != sysUsersBefore {
t.Errorf("恢复赛事库后系统库 users = %d,期望 %d", got, sysUsersBefore)
}
// ⑤ 备份文件本身还在(恢复是复制,不是搬走)。
if _, err := os.Stat(eventBackup.Path); err != nil {
t.Errorf("恢复后原备份应当保留: %v", err)
}
if _, err := os.Stat(eventPath + "-wal"); !os.IsNotExist(err) {
t.Errorf("恢复后不应残留 -wal,err=%v", err)
}
}
// 恢复系统库:只换 system.db,赛事库一行不动;审计照旧写进赛事库。
func TestRestoreSystemBackupLeavesEventDBAlone(t *testing.T) {
env, eventPath, dataDir := backupRestoreEnv(t)
env.seed(t)
client := env.loginAdmin(t)
status, _ := env.do(client, http.MethodPost, "/api/backup", map[string]any{}, true)
if status != http.StatusOK {
t.Fatalf("手动备份 status=%d", status)
}
sysBackup := findBackup(t, env.listBackups(t, client), "system-")
// 备份之后再建一个用户:恢复后它应当消失。
env.createUser("late-user", "pass-9999", "recorder")
sysPath := filepath.Join(dataDir, "system.db")
before := countRowsAt(t, sysPath, "users")
status, resp := env.do(client, http.MethodPost, "/api/backup/restore",
map[string]any{"file": sysBackup.Name, "kind": "system"}, true)
if status != http.StatusOK {
t.Fatalf("恢复系统库 status=%d %+v", status, resp.Error)
}
result := decodeData[struct {
Kind string `json:"kind"`
DBKind string `json:"dbKind"`
SafetyCopy string `json:"safetyCopy"`
SafetyCopyPath string `json:"safetyCopyPath"`
}](t, resp)
if result.Kind != "system" || result.DBKind != "system" || !strings.HasPrefix(result.SafetyCopy, "pre-restore-system-") {
t.Errorf("回执 = %+v,期望 kind=system dbKind=system 且安全副本是 pre-restore-system-", result)
}
if got := countRowsAt(t, sysPath, "users"); got != before-1 {
t.Errorf("恢复后 users = %d,期望 %d(备份时刻,晚建的用户消失)", got, before-1)
}
// 安全副本保留的是恢复前的系统库(含那个晚建的用户)。
if got := countRowsAt(t, result.SafetyCopyPath, "users"); got != before {
t.Errorf("安全副本 users = %d,期望 %d", got, before)
}
// 赛事库没被牵连。
if got := countRowsAt(t, eventPath, "teams"); got != 2 {
t.Errorf("恢复系统库后赛事库 teams = %d,期望 2", got)
}
if action, _, _ := lastAuditAt(t, eventPath); action != "system.restore" {
t.Errorf("审计 action = %q,期望 system.restore", action)
}
}
-15
View File
@@ -99,21 +99,6 @@ func normalizeRecordBonusMode(v string) (string, error) {
}
}
// normalizeEntryStatus 校验 JSON 接口传入的 entries.status(0005 的 CHECK)。
//
// 只走报名表 CSV/XLSX 导入那条链路时用不到本函数:导入有 normalizeImportStatus,
// 它认中文别名并给出行级提示;本函数只服务于 JSON 入参,不认别名。
func normalizeEntryStatus(v string) (string, error) {
switch strings.TrimSpace(v) {
case "":
return "registered", nil
case "registered", "scratched", "dq", "dns":
return strings.TrimSpace(v), nil
default:
return "", badRequest("status 只能是 registered / scratched / dq / dns", v)
}
}
// normalizeResultStatus 校验 results.status(0005 的 CHECK)。
func normalizeResultStatus(v string) (string, error) {
switch strings.TrimSpace(v) {
@@ -1,181 +0,0 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// createEntry / updateEntry 目前没有挂路由,只能在包内直接调用验证。
func newEntryTestServer(t *testing.T) (*server, *sql.DB) {
t.Helper()
db, err := sql.Open("sqlite",
"file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return &server{ev: db, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, db
}
type entryFixture struct {
eventA, eventB int64
catA, catB int64
team int64
}
func seedEntryFixture(t *testing.T, db *sql.DB) entryFixture {
t.Helper()
exec := func(query string, args ...any) int64 {
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("取 id 失败: %v", err)
}
return id
}
sport := exec(`INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
var f entryFixture
f.eventA = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'A100', '甲组100米', 'pending')`, sport)
f.eventB = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'B100', '乙组100米', 'pending')`, sport)
f.catA = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子A', 1)`, f.eventA)
f.catB = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子B', 1)`, f.eventB)
f.team = exec(`INSERT INTO teams (code, name) VALUES ('T1', '第一队')`)
return f
}
func requireAPIStatus(t *testing.T, err error, status int) *apiError {
t.Helper()
if err == nil {
t.Fatalf("期望错误,得到 nil")
}
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("期望 *apiError,得到 %T: %v", err, err)
}
if api.status != status {
t.Fatalf("期望状态 %d,得到 %d (%s)", status, api.status, api.message)
}
return api
}
func TestNormalizeEntryStatus(t *testing.T) {
for _, tc := range []struct{ raw, want string }{
{"", "registered"},
{"dns", "dns"},
{" scratched ", "scratched"},
} {
got, err := normalizeEntryStatus(tc.raw)
if err != nil || got != tc.want {
t.Fatalf("normalizeEntryStatus(%q) = %q, %v;期望 %q", tc.raw, got, err, tc.want)
}
}
got, err := normalizeEntryStatus("bogus")
if got != "" || err == nil {
t.Fatalf("非法 status 应报错,得到 %q, %v", got, err)
}
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.code != "VALIDATION_FAILED" {
t.Fatalf("非法 status 的 code 应是 VALIDATION_FAILED,得到 %q", api.code)
}
if !strings.Contains(api.message, "status") {
t.Fatalf("错误文案应带字段名,得到 %q", api.message)
}
if api.detail != "bogus" {
t.Fatalf("detail 应是原始值,得到 %v", api.detail)
}
}
func TestCreateEntryRejectsForeignCategory(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
ctx := context.Background()
_, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catB, TeamID: f.team, Status: "registered"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.message != "categoryId 不属于该小项" {
t.Fatalf("错误文案应是「categoryId 不属于该小项」,得到 %q", api.message)
}
if api.detail != f.catB {
t.Fatalf("detail 应是 categoryId %d,得到 %v", f.catB, api.detail)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
t.Fatalf("统计报名项: %v", err)
}
if n != 0 {
t.Fatalf("组别不属于小项时不该落库,entries 有 %d 行", n)
}
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "dq"})
if err != nil {
t.Fatalf("同小项组别应创建成功: %v", err)
}
if created.CategoryID != f.catA || created.EventID != f.eventA || created.Status != "dq" {
t.Fatalf("报名项字段不对: %+v", created)
}
}
func TestCreateEntryRejectsUnknownStatus(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
_, err := s.createEntry(context.Background(),
entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "bogus"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.code != "VALIDATION_FAILED" || !strings.Contains(api.message, "status") {
t.Fatalf("非法 status 应 400 VALIDATION_FAILED 且带字段名,得到 %+v", api)
}
}
func TestUpdateEntryCategoryCheckKeepsNotFound(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
ctx := context.Background()
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "registered"})
if err != nil {
t.Fatalf("准备报名项失败: %v", err)
}
// 组别必须属于该报名项所在小项(updateEntry 不改 event_id)。
_, err = s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catB, TeamID: f.team, Status: "registered"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.message != "categoryId 不属于该小项" || api.detail != f.catB {
t.Fatalf("非法组别应被拦下,得到 %+v", api)
}
updated, err := s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "scratched"})
if err != nil {
t.Fatalf("同小项组别应更新成功: %v", err)
}
if updated.CategoryID != f.catA || updated.Status != "scratched" {
t.Fatalf("更新结果不对: %+v", updated)
}
// 报名项不存在仍是 404,不能被组别校验降级成 400。
_, err = s.updateEntry(ctx, created.ID+9999, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "registered"})
api = requireAPIStatus(t, err, http.StatusNotFound)
if api.message != "报名项不存在" {
t.Fatalf("不存在的报名项应 404「报名项不存在」,得到 %+v", api)
}
}
@@ -525,3 +525,611 @@ func sameStrings(a, b []string) bool {
}
return true
}
// ---------- 排序列:留空 = 不动,显式填 0 = 写 0 ----------
// baseDataSheetNames 是导出 / 导入的四张工作表,顺序与表头识别无关。
var baseDataSheetNames = []string{"单位", "大项", "小项", "组别"}
// findHeaderColumn 找出工作表里表头等于 header 的列:返回表头行下标(0 起)与列下标。
// 表头行与列号都扫出来,不写死版式:导出加点什么也不会让测试指错地方。
func findHeaderColumn(t *testing.T, f *excelize.File, sheet, header string) (int, int) {
t.Helper()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
for r, row := range rows {
for c, v := range row {
if strings.TrimSpace(v) == header {
return r, c
}
}
}
t.Fatalf("工作表 %s 里找不到「%s」表头", sheet, header)
return -1, -1
}
// columnCells 找出工作表里表头等于 header 的那一列上、每个数据行的单元格坐标。
func columnCells(t *testing.T, f *excelize.File, sheet, header string) []string {
t.Helper()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
headerRow, col := findHeaderColumn(t, f, sheet, header)
var cells []string
for r := headerRow + 1; r < len(rows); r++ {
blank := true
for _, v := range rows[r] {
if strings.TrimSpace(v) != "" {
blank = false
break
}
}
if blank {
continue
}
cell, err := excelize.CoordinatesToCellName(col+1, r+1)
if err != nil {
t.Fatalf("算单元格坐标: %v", err)
}
cells = append(cells, cell)
}
if len(cells) == 0 {
t.Fatalf("工作表 %s 的「%s」列没有任何数据行", sheet, header)
}
return cells
}
// rewriteColumn 把某列的数据格全部改成 value("" 表示列在、格子空)。
func rewriteColumn(t *testing.T, f *excelize.File, sheet, header, value string) {
t.Helper()
for _, cell := range columnCells(t, f, sheet, header) {
if err := f.SetCellValue(sheet, cell, value); err != nil {
t.Fatalf("改 %s!%s: %v", sheet, cell, err)
}
}
}
// dropColumn 整列删掉(表头与数据一起没),模拟现场把这一列删了:
// 导入侧看到的就是「列缺席」——表头里找不到这一列,Has[field] 为 false。
func dropColumn(t *testing.T, f *excelize.File, sheet, header string) {
t.Helper()
_, col := findHeaderColumn(t, f, sheet, header)
name, err := excelize.ColumnNumberToName(col + 1)
if err != nil {
t.Fatalf("算列名: %v", err)
}
if err := f.RemoveCol(sheet, name); err != nil {
t.Fatalf("删 %s 的「%s」列: %v", sheet, header, err)
}
}
// rewriteSortColumn 把某个 sheet 的排序单元格全部改成 value("" 表示留空)。
func rewriteSortColumn(t *testing.T, f *excelize.File, sheet, value string) {
t.Helper()
rewriteColumn(t, f, sheet, "排序", value)
}
func writeWorkbook(t *testing.T, f *excelize.File) []byte {
t.Helper()
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写回工作簿: %v", err)
}
return buf.Bytes()
}
// seedDistinctSortOrders 把四张表的排序改成互不相同的非零值,并补一个大项:
// 只有非零原值才能把「留空 = 保持原值」与「留空 = 写成 0」区分开。
func seedDistinctSortOrders(t *testing.T, env *testEnv, ids seedIDs) {
t.Helper()
env.exec(t, `UPDATE teams SET sort_order = 1 WHERE id = ?`, ids.teamA)
env.exec(t, `UPDATE teams SET sort_order = 6 WHERE id = ?`, ids.teamB)
env.exec(t, `UPDATE sports SET sort_order = 1 WHERE id = ?`, ids.sport)
env.exec(t, `INSERT INTO sports (code, name, short_name, sort_order, active) VALUES ('SWIM', '游泳', '游', 6, 1)`)
env.exec(t, `UPDATE events SET sort_order = 1 WHERE id = ?`, ids.event)
env.exec(t, `UPDATE event_categories SET sort_order = 1 WHERE id = ?`, ids.category)
}
// baseDataOrigSorts 是种子数据在库里的排序原值,按 sheet 列出(行序同 baseDataSortRows)。
func baseDataOrigSorts() map[string][]int {
return map[string][]int{"单位": {1, 6}, "大项": {1, 6}, "小项": {1}, "组别": {1}}
}
// baseDataSortRows 是排序断言要查的行:sheet / 名字用于报错与分组,SQL 取库里现值。
var baseDataSortRows = []struct {
sheet string
name string
query string
}{
{"单位", "单位 A", `SELECT sort_order FROM teams WHERE code = 'A'`},
{"单位", "单位 B", `SELECT sort_order FROM teams WHERE code = 'B'`},
{"大项", "大项 TRACK", `SELECT sort_order FROM sports WHERE code = 'TRACK'`},
{"大项", "大项 SWIM", `SELECT sort_order FROM sports WHERE code = 'SWIM'`},
{"小项", "小项 M100", `SELECT sort_order FROM events WHERE code = 'M100'`},
{"组别", "组别 男子", `SELECT sort_order FROM event_categories WHERE name = '男子'`},
}
// assertSortOrders 断言四张表的排序:want 按 sheet 给出该表每一行的期望值。
func assertSortOrders(t *testing.T, env *testEnv, want map[string][]int) {
t.Helper()
next := map[string]int{}
for _, row := range baseDataSortRows {
var got int
if err := env.ev.QueryRow(row.query).Scan(&got); err != nil {
t.Fatalf("查询%s排序: %v", row.name, err)
}
values, ok := want[row.sheet]
if !ok || next[row.sheet] >= len(values) {
t.Fatalf("断言表缺 %s 的期望值", row.sheet)
}
if expected := values[next[row.sheet]]; got != expected {
t.Errorf("%s 排序 = %d,期望 %d", row.name, got, expected)
}
next[row.sheet]++
}
}
// zeroedSorts 取一份「本 sheet 全为 0、其余保持原值」的期望表。
func zeroedSorts(sheet string) map[string][]int {
want := baseDataOrigSorts()
want[sheet] = make([]int, len(want[sheet]))
return want
}
// baseDataAdminClient 建一个 admin 账号并登录,返回带会话与 CSRF Cookie 的客户端。
func baseDataAdminClient(t *testing.T, env *testEnv) *http.Client {
t.Helper()
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
return client
}
// exportBaseDataWorkbook 导出基础数据包并打开工作簿(改完用 writeWorkbook 写回)。
func exportBaseDataWorkbook(t *testing.T, env *testEnv, client *http.Client) *excelize.File {
t.Helper()
status, _, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
if status != http.StatusOK {
t.Fatalf("导出基础数据包应 200,得到 %d %s", status, body)
}
f := openWorkbook(t, body)
t.Cleanup(func() { _ = f.Close() })
return f
}
// importPackage 上传基础数据包并解出结果(fields 给 dryRun=1 就是预览)。
func importPackage(t *testing.T, env *testEnv, client *http.Client, pack []byte, fields map[string]string) baseDataResult {
t.Helper()
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, fields)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
return decodeBaseDataResult(t, raw)
}
// assertNoFieldDiff 断言差异里没有这几个字段(表格里 / 库里的中文字段名)。
func assertNoFieldDiff(t *testing.T, res baseDataResult, fields ...string) {
t.Helper()
unwanted := make(map[string]bool, len(fields))
for _, name := range fields {
unwanted[name] = true
}
for _, ch := range res.Changes {
for _, fld := range ch.Fields {
if unwanted[fld.Field] {
t.Errorf("%s %s 的 %s 不该有差异,得到 %s→%s", ch.Sheet, ch.Key, fld.Field, fld.From, fld.To)
}
}
}
}
// assertNoSortDiff 断言差异里没有「排序」这一列。
func assertNoSortDiff(t *testing.T, res baseDataResult) {
t.Helper()
assertNoFieldDiff(t, res, "排序")
}
// 排序列留空 = 保持原值:四个 sheet 各一条(曾经单位 / 大项被写成 0)。
func TestBaseDataImportBlankSortOrderKeepsExisting(t *testing.T) {
for _, sheet := range baseDataSheetNames {
t.Run(sheet, func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
seedDistinctSortOrders(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteSortColumn(t, f, sheet, "")
pack := writeWorkbook(t, f)
// 预览:只清空了排序列,一条差异都不该有。
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if !res.DryRun {
t.Errorf("dryRun 应为 true")
}
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("排序留空不该产生差异,得到 新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
if res.Unchanged != 6 {
t.Fatalf("未变行数 = %d,期望 6(四张表 6 行都被认出来了)", res.Unchanged)
}
assertNoSortDiff(t, res)
// 真写入:库里的排序仍是原值。
res = importPackage(t, env, client, pack, nil)
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("排序留空不该写库,得到 新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
assertSortOrders(t, env, baseDataOrigSorts())
})
}
}
// 反向:排序列显式填 0 是「写 0」,不是「留空」——证明修的是区分,不是把排序列冻住。
func TestBaseDataImportExplicitZeroSortOrderIsWritten(t *testing.T) {
for _, sheet := range baseDataSheetNames {
t.Run(sheet, func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
seedDistinctSortOrders(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteSortColumn(t, f, sheet, "0")
pack := writeWorkbook(t, f)
wantUpdated := len(baseDataOrigSorts()[sheet])
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.ErrorCount != 0 {
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
if res.Updated != wantUpdated {
t.Fatalf("更新条数 = %d,期望 %d(%s 的排序被显式改成 0)", res.Updated, wantUpdated, sheet)
}
for _, ch := range res.Changes {
if ch.Sheet != sheet {
t.Errorf("只改了 %s,不该动 %s", sheet, ch.Sheet)
}
for _, fld := range ch.Fields {
if fld.Field != "排序" || fld.To != "0" {
t.Errorf("%s %s 出现了排序以外的差异:%s %s→%s", ch.Sheet, ch.Key, fld.Field, fld.From, fld.To)
}
}
}
res = importPackage(t, env, client, pack, nil)
if res.Updated != wantUpdated {
t.Fatalf("落地后更新条数 = %d,期望 %d", res.Updated, wantUpdated)
}
assertSortOrders(t, env, zeroedSorts(sheet))
})
}
}
// ---------- 布尔列:缺列 = 不动,格子空 = 取「是」,显式「否」= 停用 ----------
// baseDataBoolSheets 是带布尔列的 sheet 与两者的对照基线(组别表没有任何布尔列,故不在表里):
//
// blankUpdated:清空布尔格后应被更新的行数(原本就是「是」的行不算差异)
// noUpdated:显式填「否」后应被更新的行数(这一组用纯种子,所有行原本都是「是」)
var baseDataBoolSheets = []struct {
sheet string
headers []string
blankUpdated int
noUpdated int
}{
{"单位", []string{"启用"}, 1, 2},
{"大项", []string{"启用"}, 1, 2},
{"小项", []string{"计分", "计破纪录"}, 1, 1},
}
// yesNo 把布尔值渲染成界面上那一列的文字。
func yesNo(v bool) string {
if v {
return "是"
}
return "否"
}
// boolExpect 给出该 sheet 的布尔字段「全部为 yes」时应有的库内渲染,版式与 boolState 一致。
func boolExpect(sheet string, yes bool) string {
switch sheet {
case "单位":
return "A=" + yesNo(yes) + " B=" + yesNo(yes)
case "大项":
return "SWIM=" + yesNo(yes) + " TRACK=" + yesNo(yes)
case "小项":
return "M100 计分=" + yesNo(yes) + " 计破纪录=" + yesNo(yes)
default:
return ""
}
}
// boolState 读出该 sheet 布尔字段的库内现状,渲染成与 boolExpect 同版式的字符串。
func boolState(t *testing.T, env *testEnv, sheet string) string {
t.Helper()
switch sheet {
case "单位", "大项":
table := "teams"
if sheet == "大项" {
table = "sports"
}
rows, err := env.ev.Query(`SELECT code, active FROM ` + table + ` ORDER BY code`)
if err != nil {
t.Fatalf("查询%s: %v", sheet, err)
}
defer func() { _ = rows.Close() }()
var parts []string
for rows.Next() {
var (
code string
active int
)
if err := rows.Scan(&code, &active); err != nil {
t.Fatalf("扫描%s: %v", sheet, err)
}
parts = append(parts, code+"="+yesNo(active != 0))
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历%s: %v", sheet, err)
}
return strings.Join(parts, " ")
case "小项":
var scored, recordEligible int
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = 'M100'`).
Scan(&scored, &recordEligible); err != nil {
t.Fatalf("查询小项布尔字段: %v", err)
}
return "M100 计分=" + yesNo(scored != 0) + " 计破纪录=" + yesNo(recordEligible != 0)
default:
t.Fatalf("sheet %s 没有布尔列,不该拿它查布尔状态", sheet)
return ""
}
}
// seedDisabledBooleans 造出「本来就有一半是停用」的现状:
// 单位 B 停用、大项 SWIM 停用、小项 M100 不计分也不计破纪录。
// 只有原本为「否」的行,才能把「缺列 = 不动」和「缺列 = 改成是」区分开。
func seedDisabledBooleans(t *testing.T, env *testEnv, ids seedIDs) {
t.Helper()
env.exec(t, `UPDATE teams SET active = 0 WHERE id = ?`, ids.teamB)
env.exec(t, `UPDATE sports SET active = 0 WHERE code = 'SWIM'`)
env.exec(t, `UPDATE events SET scored = 0, record_eligible = 0 WHERE id = ?`, ids.event)
}
// assertBoolDiff 断言差异只出现在该 sheet 的布尔列上,且都是 wantFrom→wantTo。
func assertBoolDiff(t *testing.T, res baseDataResult, sheet string, headers []string, wantFrom, wantTo string) {
t.Helper()
allowed := make(map[string]bool, len(headers))
for _, h := range headers {
allowed[h] = true
}
seen := 0
for _, ch := range res.Changes {
if ch.Sheet != sheet {
t.Errorf("只改了 %s,不该动 %s", sheet, ch.Sheet)
continue
}
for _, fld := range ch.Fields {
if !allowed[fld.Field] {
t.Errorf("%s %s 出现了布尔列以外的差异:%s %s→%s", ch.Sheet, ch.Key, fld.Field, fld.From, fld.To)
continue
}
if fld.From != wantFrom || fld.To != wantTo {
t.Errorf("%s %s 的 %s = %s→%s,期望 %s→%s",
ch.Sheet, ch.Key, fld.Field, fld.From, fld.To, wantFrom, wantTo)
}
seen++
}
}
if seen == 0 {
t.Errorf("%s 一条布尔差异都没有,用例没测到东西", sheet)
}
}
// 布尔列整列删掉 = 不动该字段(组别表没有布尔列,只覆盖三张表)。
// 关键对照:库里原本停用的行,导入后仍是停用——绝不出现「少写一列 = 全部启用」。
func TestBaseDataImportAbsentBooleanColumnKeepsExisting(t *testing.T) {
for _, tc := range baseDataBoolSheets {
t.Run(tc.sheet, func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
seedDistinctSortOrders(t, env, ids)
seedDisabledBooleans(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
for _, header := range tc.headers {
dropColumn(t, f, tc.sheet, header)
}
pack := writeWorkbook(t, f)
before := boolState(t, env, tc.sheet)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("布尔列缺列不该产生差异,得到 新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
if res.Unchanged != 6 {
t.Fatalf("未变行数 = %d,期望 6(四张表 6 行都被认出来了)", res.Unchanged)
}
assertNoFieldDiff(t, res, tc.headers...)
res = importPackage(t, env, client, pack, nil)
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("布尔列缺列不该写库,得到 新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
if after := boolState(t, env, tc.sheet); after != before {
t.Errorf("布尔字段被改动了:%s → %s(缺列应保持原值)", before, after)
}
})
}
}
// 布尔列在、格子空 = 取「是」:与「缺列」区分开(缺列那半边见上一个用例)。
func TestBaseDataImportBlankBooleanCellMeansYes(t *testing.T) {
for _, tc := range baseDataBoolSheets {
t.Run(tc.sheet, func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
seedDistinctSortOrders(t, env, ids)
seedDisabledBooleans(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
for _, header := range tc.headers {
rewriteColumn(t, f, tc.sheet, header, "")
}
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.ErrorCount != 0 {
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
if res.Updated != tc.blankUpdated {
t.Fatalf("更新条数 = %d,期望 %d(%s 原本为「否」的行应变成「是」)",
res.Updated, tc.blankUpdated, tc.sheet)
}
assertBoolDiff(t, res, tc.sheet, tc.headers, "否", "是")
res = importPackage(t, env, client, pack, nil)
if res.Updated != tc.blankUpdated {
t.Fatalf("落地后更新条数 = %d,期望 %d", res.Updated, tc.blankUpdated)
}
if got, want := boolState(t, env, tc.sheet), boolExpect(tc.sheet, true); got != want {
t.Errorf("布尔字段 = %s,期望 %s(格子空取「是」)", got, want)
}
})
}
}
// 布尔列显式填「否」= 真的停用:证明改的是「缺列 / 空格 vs 显式值」的区分,不是把布尔列冻住。
func TestBaseDataImportExplicitNoDisables(t *testing.T) {
for _, tc := range baseDataBoolSheets {
t.Run(tc.sheet, func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
seedDistinctSortOrders(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
for _, header := range tc.headers {
rewriteColumn(t, f, tc.sheet, header, "否")
}
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.ErrorCount != 0 {
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
if res.Updated != tc.noUpdated {
t.Fatalf("更新条数 = %d,期望 %d(%s 全表显式改成「否」)", res.Updated, tc.noUpdated, tc.sheet)
}
assertBoolDiff(t, res, tc.sheet, tc.headers, "是", "否")
res = importPackage(t, env, client, pack, nil)
if res.Updated != tc.noUpdated {
t.Fatalf("落地后更新条数 = %d,期望 %d", res.Updated, tc.noUpdated)
}
if got, want := boolState(t, env, tc.sheet), boolExpect(tc.sheet, false); got != want {
t.Errorf("布尔字段 = %s,期望 %s(显式「否」应写成停用)", got, want)
}
})
}
}
// 排序列整列删掉 = 不动该字段:单位 / 大项各一条(补上上一轮只测了「格子空」的另一半)。
func TestBaseDataImportAbsentSortColumnKeepsExisting(t *testing.T) {
for _, sheet := range []string{"单位", "大项"} {
t.Run(sheet, func(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
seedDistinctSortOrders(t, env, ids)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
dropColumn(t, f, sheet, "排序")
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("排序列缺列不该产生差异,得到 新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
if res.Unchanged != 6 {
t.Fatalf("未变行数 = %d,期望 6(四张表 6 行都被认出来了)", res.Unchanged)
}
assertNoSortDiff(t, res)
res = importPackage(t, env, client, pack, nil)
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("排序列缺列不该写库,得到 新增 %d / 更新 %d / 错误 %d %v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
assertSortOrders(t, env, baseDataOrigSorts())
})
}
}
// 对照:这一轮只统一布尔列的「缺列」口径,别的列一个字没动——
// 文本列留空仍清空、权重留空仍取 1(两条已知且被文档写准的口径)。
func TestBaseDataImportBlankTextAndWeightKeepTheirRules(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.exec(t, `UPDATE event_categories SET multiplier = 2 WHERE id = ?`, ids.category)
client := baseDataAdminClient(t, env)
f := exportBaseDataWorkbook(t, env, client)
rewriteColumn(t, f, "单位", "简称", "")
rewriteColumn(t, f, "组别", "权重", "")
pack := writeWorkbook(t, f)
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
if res.Added != 0 || res.ErrorCount != 0 {
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
if res.Updated != 3 {
t.Fatalf("更新条数 = %d,期望 3(2 个单位清掉简称 + 1 个组别回到权重 1)", res.Updated)
}
seen := map[string]string{}
for _, ch := range res.Changes {
for _, fld := range ch.Fields {
seen[ch.Sheet+"/"+ch.Key+"."+fld.Field] = fld.From + "→" + fld.To
}
}
if got := seen["单位/A.简称"]; got != "一司→" {
t.Errorf("单位 A 简称差异 = %q,期望 一司→(空)", got)
}
if got := seen["单位/B.简称"]; got != "二司→" {
t.Errorf("单位 B 简称差异 = %q,期望 二司→(空)", got)
}
if got := seen["组别/M100 / 男子.权重"]; got != "2→1" {
t.Errorf("组别权重差异 = %q,期望 2→1", got)
}
res = importPackage(t, env, client, pack, nil)
if res.Updated != 3 {
t.Fatalf("落地后更新条数 = %d,期望 3", res.Updated)
}
var shortName string
if err := env.ev.QueryRow(`SELECT COALESCE(short_name, '') FROM teams WHERE code = 'A'`).Scan(&shortName); err != nil {
t.Fatalf("查询单位简称: %v", err)
}
if shortName != "" {
t.Errorf("文本列留空应清空,得到 %q", shortName)
}
var multiplier float64
if err := env.ev.QueryRow(`SELECT multiplier FROM event_categories WHERE id = ?`, ids.category).
Scan(&multiplier); err != nil {
t.Fatalf("查询组别权重: %v", err)
}
if multiplier != 1 {
t.Errorf("权重留空应取 1,得到 %v", multiplier)
}
}
+261 -4
View File
@@ -605,7 +605,14 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
p.rowError(sheet, line, "单位名称必填")
continue
}
plan := baseDataTeamPlan{line: line, code: code, name: name, active: true}
// 起手值一律取库里现值:**列缺席 = 不动该字段**(布尔列也一样,见下面的解析块),
// 列在但格子空才落到「取默认值」:排序空值不改起手值、启用空值由解析取「是」。
// 新建时 p.teams 取不到,给与建表默认一致的起手值:启用、排序 0。
existing, ok := p.teams[code]
if !ok {
existing = baseDataExistingTeam{active: true}
}
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)
if !ok {
@@ -624,7 +631,6 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
plan.sortOrder = *n
}
}
existing, ok := p.teams[code]
if !ok {
plan.action = "added"
if row.Has["shortName"] {
@@ -685,7 +691,13 @@ func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
p.rowError(sheet, line, "大项名称必填")
continue
}
plan := baseDataSportPlan{line: line, code: code, name: name, active: true}
// 与单位同口径:起手取库里现值,列缺席 = 不动该字段(启用也一样);
// 列在(row.Has 为真)才走下面的解析块,格子空由它取默认「是」。新建才落默认:启用、排序 0。
existing, ok := p.sports[code]
if !ok {
existing = baseDataExistingSport{active: true}
}
plan := baseDataSportPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
if row.Has["active"] {
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
if !ok {
@@ -704,7 +716,6 @@ func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
plan.sortOrder = *n
}
}
existing, ok := p.sports[code]
if !ok {
plan.action = "added"
if row.Has["shortName"] {
@@ -1447,3 +1458,249 @@ func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error
}
return items, nil
}
// backupRestoreResult 是恢复请求被接受后的回执。
//
// SafetyCopy 是「恢复前安全副本」的文件名,必须回报给操作员:
// 应用这次恢复的理由是数据被改乱了,那恢复本身错了(选错时间点 / 选错库)的概率就不低,
// 现场要能立刻拿副本退回。
type backupRestoreResult struct {
// Kind 是这次用的文件的性质:system / event / safety(安全副本也能拿来退回)。
Kind string `json:"kind"`
// DBKind 是被恢复的库:system / event。
DBKind string `json:"dbKind"`
KindLabel string `json:"kindLabel"`
File string `json:"file"`
Target string `json:"target"`
// SafetyCopy 与本策略同名文件在备份目录里的完整路径;PrunedSafetyCopies 是这次顺带清掉的旧副本份数。
SafetyCopy string `json:"safetyCopy"`
SafetyCopyPath string `json:"safetyCopyPath"`
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
Restarting bool `json:"restarting"`
Message string `json:"message"`
Warning string `json:"warning,omitempty"`
}
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
//
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
// 不在运行中热替换——Windows 上替换不了正被打开的文件,硬写会把库写坏;
// 所以这里先把目标库的连接关掉(服务端从此不再持有它),整文件替换,再触发重启。
//
// 顺序是有讲究的,任何一步失败都不能留下「半截」状态:
//
// 校验备份(坏文件直接 400,当前库毫发无伤)
// → 给当前库拍安全副本(失败就放弃,什么都没动)
// → 落恢复标记(死了也有线索)
// → 关连接、替换文件
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in restoreBackupPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
kind, ok := export.ParseRestoreKind(in.Kind)
if !ok {
return nil, badRequest("请指定要恢复的库:kind 只能是 system(系统库)或 event(赛事库)", nil)
}
name := strings.TrimSpace(in.File)
if name == "" {
return nil, badRequest("请选择要恢复的备份文件", nil)
}
// 只收裸文件名:带分隔符(或 ..)的输入会把恢复变成「任意文件覆盖任意路径」。
if name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
return nil, badRequest("备份文件名非法,只能填备份目录里的文件名", nil)
}
fileKind, fileDBKind, ok := export.KindOfBackupName(name)
if !ok {
return nil, badRequest("无法从这个文件名判断它属于哪个库,请从备份列表里选择", nil)
}
// 比的是「它对应的库」:安全副本的性质是 safety,但覆盖的仍是它那一份库,
// 拿安全副本退回上一个时间点是正当操作(列表接口会给出 dbKind)。
if fileDBKind != kind {
return nil, badRequest(fmt.Sprintf("%s 是%s的备份,不能恢复成%s;请确认要恢复的库",
name, fileDBKind.Label(), kind.Label()), nil)
}
backupsDir := filepath.Join(s.dataDir, "backups")
backupPath := filepath.Join(backupsDir, name)
if err := export.ValidateBackup(backupPath, kind); err != nil {
return nil, badRequest(trimExportPrefix(err.Error()), nil)
}
target, db, apiErr := s.restoreTarget(kind)
if apiErr != nil {
return nil, apiErr
}
// 上一次恢复(或任何原因)已经把连接关掉了:这时再往下走,拍安全副本只会撞上
// 「sql: database is closed」,报出来是一句看不懂的 500。直接说清「先重启服务端」。
if err := db.PingContext(r.Context()); err != nil && dbClosed(err) {
return nil, serviceUnavailable("INTERNAL", "数据库连接已关闭(上一次恢复后尚未重启),请先重启服务端再恢复")
}
safety, err := export.SafetySnapshot(db, backupsDir, kind, time.Now())
if err != nil {
return nil, internalMsg("生成恢复前安全副本失败,当前库未做任何改动", err)
}
// 安全副本自己那份保留策略(每类库最近 5 份,最新的永不删),与普通备份的 20 份滚动互不干扰。
// 清不掉旧副本不算恢复失败:磁盘上多留一份整库文件,比因此拒绝一次现场恢复划算得多。
pruned := 0
if removed, pruneErr := export.PruneSafetyCopies(backupsDir, kind, export.DefaultSafetyCopyKeep); pruneErr != nil {
s.logger.Warn("清理旧安全副本失败", "kind", string(kind), "err", pruneErr)
} else {
pruned = len(removed)
if pruned > 0 {
s.logger.Info("已清理旧安全副本", "kind", string(kind),
"removed", pruned, "keep", export.DefaultSafetyCopyKeep)
}
}
operator := "guest"
if u := middleware.User(r.Context()); u != nil && u.ID != 0 {
operator = fmt.Sprintf("%s#%d", u.Username, u.ID)
}
markerPath, err := export.WritePendingRestore(s.dataDir, export.PendingRestore{
Kind: kind,
Backup: backupPath,
BackupName: name,
Target: target,
SafetyCopy: safety.Path,
Operator: operator,
RequestedAt: time.Now(),
})
if err != nil {
return nil, internalMsg("写入恢复标记失败,当前库未做任何改动", err)
}
// 停机第一步:断开目标库的全部连接。此后服务端不再持有这个库,
// 直到本次请求触发优雅退出、Console 把服务端重新拉起来。
if err := db.Close(); err != nil {
s.logger.Warn("关闭目标库连接失败,继续恢复", "kind", string(kind), "err", err)
}
if err := export.ApplyRestore(backupPath, target); err != nil {
// 连接已经断开,即使文件没换成,也得让服务端重启一次把库重新打开。
s.triggerRestart(kind)
return nil, internalMsg("替换数据库失败,当前库文件未被改动;恢复标记留在 "+markerPath+
";请用恢复前安全副本 "+safety.Path+" 退回", err)
}
// 备份在替换前校验过一次,这里是兜底复核:万一新库打不开,立刻退回安全副本,
// 绝不能让一次恢复留下一个打不开的库。
if err := export.ValidateBackup(target, kind); err != nil {
if rbErr := export.ApplyRestore(safety.Path, target); rbErr != nil {
s.triggerRestart(kind)
return nil, internalMsg("恢复后校验失败,且自动回滚也失败;请立即用安全副本 "+
safety.Path+" 手工恢复", err)
}
_ = export.ClearPendingRestore(s.dataDir)
s.triggerRestart(kind)
return nil, internalMsg("恢复后校验失败,已自动回滚到恢复前状态(安全副本 "+safety.Name+")", err)
}
_ = export.ClearPendingRestore(s.dataDir)
// 审计直接写给「恢复之后还在的那份库」(审计表在赛事库);
// 恢复赛事库时旧连接已关、旧文件已换,走中间件的审计只会留在被换掉的文件上。
warning := ""
if err := s.recordRestoreAudit(r, kind, name, target, safety.Name); err != nil {
s.logger.Error("恢复审计写入失败", "kind", string(kind), "err", err)
warning = "恢复已完成,但审计写入失败:" + err.Error()
}
base := fmt.Sprintf("已用 %s 恢复%s", name, kind.Label())
if s.shutdown == nil {
// 裸服务端(没接 Console)没有可触发的优雅重启,库文件已经换好但进程还抱着旧连接,
// 只能由操作员手工重启——回执必须说实话,不能让人以为它会自己回来。
base += "。服务端未接入自动重启,请手工重启服务后刷新页面"
} else {
base += ";服务端正在重启,请稍候刷新页面"
}
notes := []string{"恢复前安全副本:" + safety.Name}
if pruned > 0 {
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
pruned, export.DefaultSafetyCopyKeep))
}
if kind == export.RestoreKindEvent {
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。
notes = append(notes, "赛事库备份的文件名里不含来源项目,恢复前请确认来源")
}
result := backupRestoreResult{
Kind: fileKind,
DBKind: string(kind),
KindLabel: kind.Label(),
File: name,
Target: target,
SafetyCopy: safety.Name,
SafetyCopyPath: safety.Path,
PrunedSafetyCopies: pruned,
Restarting: s.shutdown != nil,
Message: base + "。" + strings.Join(notes, ";"),
Warning: warning,
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
s.triggerRestart(kind)
return nil, errHandled
}
// triggerRestart 走「切换赛事」同一条收尾:优雅退出,由 Console 拉起服务端。
//
// 恢复一旦执行到「关掉目标库连接」这一步,无论后面成没成,都必须重启一次——
// 连接已经断开,只有新进程会把库重新打开。调用点在响应写完之后:
// srv.Shutdown 会等本次请求(含响应与审计落库)结束,不会截断回执。
func (s *server) triggerRestart(kind export.RestoreKind) {
if s.shutdown == nil {
// 与切换赛事一致的降级:没接入优雅关闭时文件已经换好,下次重启即生效。
s.logger.Warn("服务端未接入优雅关闭,恢复将在下次重启后生效", "kind", string(kind))
return
}
go s.shutdown()
}
// restoreTarget 返回要恢复的库文件路径与当前连接。
// 赛事库的路径取自服务端实际打开的赛事目录,而不是从记忆文件重新推一遍:
// 恢复必须覆盖「当前正在用的那份」,退役项目里的同名文件不是目标。
func (s *server) restoreTarget(kind export.RestoreKind) (string, *sql.DB, error) {
switch kind {
case export.RestoreKindSystem:
if strings.TrimSpace(s.dataDir) == "" || s.sys == nil {
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置数据目录,无法恢复系统库")
}
return filepath.Join(s.dataDir, "system.db"), s.sys, nil
case export.RestoreKindEvent:
if strings.TrimSpace(s.projectDir) == "" || s.ev == nil {
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置赛事项目目录,无法恢复赛事库")
}
return filepath.Join(s.projectDir, "event.db"), s.ev, nil
default:
return "", nil, badRequest("要恢复的库只能是 system 或 event", nil)
}
}
// recordRestoreAudit 把这次恢复写进赛事库的 audit_logs(audit 表在赛事库,系统库没有)。
func (s *server) recordRestoreAudit(r *http.Request, kind export.RestoreKind, file, target, safetyName string) error {
if strings.TrimSpace(s.projectDir) == "" {
return errors.New("未配置赛事项目目录,没有可写审计的库")
}
detail := map[string]any{
"kind": string(kind),
"file": file,
"target": target,
"safetyCopy": safetyName,
}
if project := currentProjectName(s.projectDir); project != "" {
detail["project"] = project
}
return export.RecordRestoreAudit(filepath.Join(s.projectDir, "event.db"), export.RestoreAuditRow{
Action: "system.restore",
EntityType: "backup",
ActorID: middlewareUserID(r),
IP: middleware.ClientIP(r),
RequestID: middleware.GetRequestID(r.Context()),
Detail: detail,
})
}
// trimExportPrefix 去掉 export 包错误里的包名前缀,让提示读起来像人话。
func trimExportPrefix(message string) string {
return strings.TrimPrefix(message, "export: ")
}
+290
View File
@@ -13,6 +13,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -1294,3 +1295,292 @@ func uploadContentType(name string) (string, bool) {
ctype, ok := allowedUploadImageTypes[ext]
return ctype, ok
}
// ---------- 上传图片的删除与孤儿清理 ----------
// uploadRef 是一条「引用了某张上传图片」的配置:name 是配置值指向的文件名,label 是给操作员看的位置。
type uploadRef struct {
name string
label string
}
// uploadRefPoints 是赛事设置里的图片引用点。
// 加新的图片来源时必须同步加到这里(以及 uploadRefs 里的单位查询),
// 否则孤儿清单会把正在用的图片当成垃圾删掉。
var uploadRefPoints = []struct {
key string
label string
}{
{"event.logo_path", "赛事信息·赛事会标"},
{"display.logo", "赛事信息·大屏会标"},
{"display.background", "赛事信息·大屏背景图"},
}
// uploadRefs 扫一遍全部引用点,返回「哪张图被哪里引用」。
//
// 单位不按 deleted_at 过滤:被彻底删除(隐藏)的单位还能恢复,恢复后图标必须还在,
// 所以它的 logo 仍然算被引用——这里的方向是宁可漏删孤儿,也不能误删在用的图。
func (s *server) uploadRefs(ctx context.Context) ([]uploadRef, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return nil, err
}
var refs []uploadRef
for _, point := range uploadRefPoints {
if name, ok := uploadNameInPath(values[point.key]); ok {
refs = append(refs, uploadRef{name: name, label: point.label})
}
}
rows, err := s.ev.QueryContext(ctx,
`SELECT COALESCE(code, ''), name, COALESCE(short_name, ''), COALESCE(logo_path, '')
FROM teams WHERE COALESCE(logo_path, '') <> ''`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var code, teamName, shortName, logoPath string
if err := rows.Scan(&code, &teamName, &shortName, &logoPath); err != nil {
return nil, internal(err)
}
name, ok := uploadNameInPath(logoPath)
if !ok {
continue
}
label := firstNonBlank(teamName, shortName, code)
refs = append(refs, uploadRef{name: name, label: "单位「" + label + "」的单位图标"})
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return refs, nil
}
// referencedUploads 把 uploadRefs 汇总成 文件名 → 引用位置列表。
// 孤儿清单与删除前的复核共用它,保证「列出来是孤儿」与「删的时候判定还是孤儿」口径一致。
func (s *server) referencedUploads(ctx context.Context) (map[string][]string, error) {
refs, err := s.uploadRefs(ctx)
if err != nil {
return nil, err
}
out := make(map[string][]string, len(refs))
for _, ref := range refs {
out[ref.name] = append(out[ref.name], ref.label)
}
return out, nil
}
// uploadNameInPath 从配置值里取出它指向的上传文件名。
//
// 管理端允许手填 `uploads/xxx.png` 这类相对路径,也可能粘一整条地址,
// 所以统一去掉查询串后取最后一段路径再比对。这种「宽松识别」的方向是有意的:
// 认错(把别处同名的图当成本地文件)最多漏删一个孤儿,不认则会删掉正在用的图。
func uploadNameInPath(value string) (string, bool) {
v := strings.TrimSpace(value)
if v == "" {
return "", false
}
// Windows 手填路径用反斜杠,先归一成 /。
v = strings.ReplaceAll(v, `\`, "/")
if i := strings.LastIndexAny(v, "?#"); i >= 0 {
v = v[:i]
}
v = strings.TrimSuffix(v, "/")
if i := strings.LastIndex(v, "/"); i >= 0 {
v = v[i+1:]
}
if v == "" {
return "", false
}
return v, true
}
func firstNonBlank(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// uploadFileDTO 是孤儿清单里的一项。
type uploadFileDTO struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
type uploadOrphansDTO struct {
Items []uploadFileDTO `json:"items"`
Total int `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
// listUploadOrphansHandler 列出 uploads/ 下没有被任何引用点用到的图片(admin)。
//
// 只扫单层目录:uploads/fonts/ 是字体资源,有各自的删除接口,不归这里管。
// 目录不存在回空列表——「一张孤儿都没有」是正常状态,不是错误。
func (s *server) listUploadOrphansHandler(w http.ResponseWriter, r *http.Request) (any, error) {
dir, err := s.uploadsDir()
if err != nil {
return nil, err
}
referenced, err := s.referencedUploads(r.Context())
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return uploadOrphansDTO{Items: []uploadFileDTO{}}, nil
}
return nil, internal(err)
}
items := make([]uploadFileDTO, 0, len(entries))
var totalBytes int64
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// 复用静态访问的校验:清单里不该出现 /uploads/ 根本取不到的文件。
if _, ok := uploadContentType(name); !ok {
continue
}
if _, used := referenced[name]; used {
continue
}
// 单个条目读不到(权限 / 并发删除)只跳过它,不让整张清单失败。
info, err := entry.Info()
if err != nil {
continue
}
items = append(items, uploadFileDTO{
Name: name,
URL: "/uploads/" + name,
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
totalBytes += info.Size()
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
return uploadOrphansDTO{Items: items, Total: len(items), TotalBytes: totalBytes}, nil
}
// deleteUploadHandler 删除单张上传图片(admin)。
//
// 仍被赛事设置或单位引用时拒绝,并把「被谁引用」列出来:只回一句「正在使用」
// 会让现场不知道该去哪里改,而删掉在用的图会让大屏直接白图,比拒绝更难排查
// (与字体删除同一口径,见 fonts.go 的 ensureFontUnused)。
func (s *server) deleteUploadHandler(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
// 文件名收敛与防穿越复用静态访问的校验,不另写一套。
if _, ok := uploadContentType(name); !ok {
return notFound("图片不存在")
}
dir, err := s.uploadsDir()
if err != nil {
return err
}
referenced, err := s.referencedUploads(r.Context())
if err != nil {
return err
}
if refs := referenced[name]; len(refs) > 0 {
return conflict(uploadInUseMessage(name, refs))
}
if err := os.Remove(filepath.Join(dir, filepath.Base(name))); err != nil {
if os.IsNotExist(err) {
return notFound("图片不存在")
}
return internal(err)
}
s.audit(r.Context(), "uploads.delete", "upload", 0, map[string]any{"file": name}, nil)
return nil
}
// uploadInUseMessage 把「被谁引用」拼成操作员看得懂的拒绝理由。
func uploadInUseMessage(name string, refs []string) string {
return "图片 " + name + " 正在被使用(" + strings.Join(refs, "、") +
"),请先在对应位置改掉或清除后再删除"
}
// maxUploadDeleteBatch 限制一次批量删除的条目数,超出的请求整单拒绝而不是部分执行。
const maxUploadDeleteBatch = 500
type deleteUploadsInput struct {
Names []string `json:"names"`
}
type uploadDeleteFailure struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
type deleteUploadsResult struct {
Deleted []string `json:"deleted"`
Failed []uploadDeleteFailure `json:"failed"`
}
// deleteUploadsHandler 批量删除「请求里明确列出」的孤儿图片(admin),逐条回报结果。
//
// 红线:只动请求点名的文件,绝不「扫到没引用的就删」。清单在前端可能已经过期
// (期间有人把图配到了单位或背景上),所以删除前重新扫一遍引用,被引用的条目只回报不删。
func (s *server) deleteUploadsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in deleteUploadsInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if len(in.Names) == 0 {
return nil, badRequest("没有指定要删除的图片", nil)
}
if len(in.Names) > maxUploadDeleteBatch {
return nil, badRequest(fmt.Sprintf("一次最多删除 %d 张图片", maxUploadDeleteBatch), len(in.Names))
}
dir, err := s.uploadsDir()
if err != nil {
return nil, err
}
referenced, err := s.referencedUploads(r.Context())
if err != nil {
return nil, err
}
deleted := make([]string, 0, len(in.Names))
failed := make([]uploadDeleteFailure, 0)
seen := make(map[string]bool, len(in.Names))
for _, raw := range in.Names {
name := strings.TrimSpace(raw)
// 同一个名字重复出现只处理一次:第二条回报「文件不存在」会误导操作员。
if name == "" || seen[name] {
continue
}
seen[name] = true
if _, ok := uploadContentType(name); !ok {
failed = append(failed, uploadDeleteFailure{name, "文件名非法,已跳过"})
continue
}
if refs := referenced[name]; len(refs) > 0 {
failed = append(failed, uploadDeleteFailure{name, "仍被引用(" + strings.Join(refs, "、") + "),已跳过"})
continue
}
if err := os.Remove(filepath.Join(dir, filepath.Base(name))); err != nil {
if os.IsNotExist(err) {
failed = append(failed, uploadDeleteFailure{name, "文件不存在,已跳过"})
continue
}
failed = append(failed, uploadDeleteFailure{name, "删除失败:" + err.Error()})
continue
}
deleted = append(deleted, name)
}
if len(deleted) > 0 {
s.audit(r.Context(), "uploads.delete", "upload", 0, map[string]any{"files": deleted}, nil)
}
return deleteUploadsResult{Deleted: deleted, Failed: failed}, nil
}
@@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
@@ -1462,3 +1463,352 @@ func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
assertStoredFlags(t, "E-BOOL-OFF", 0)
assertStoredFlags(t, "E-BOOL", 0)
}
// ---------- 上传图片的删除与孤儿清理 ----------
// uploadOrphansBody 对应 GET /api/uploads/orphans 的 data。
type uploadOrphansBody struct {
Items []uploadOrphanBody `json:"items"`
Total int `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
type uploadOrphanBody struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
type uploadDeleteFailureBody struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
type uploadDeleteBody struct {
Deleted []string `json:"deleted"`
Failed []uploadDeleteFailureBody `json:"failed"`
}
// uploadAdminEnv 建好带项目目录的环境并登录 admin:
// 删文件与孤儿清理是系统级资源管理,只有 admin 能做(与字体删除同一档)。
func uploadAdminEnv(t *testing.T) (*testEnv, string, *http.Client) {
t.Helper()
env, uploadsDir := newUploadEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
return env, uploadsDir, client
}
// uploadOne 上传一张图片,返回 /uploads/<随机名>。
func uploadOne(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusOK || !body.OK {
t.Fatalf("上传图片应 200,得到 %d %+v", status, body.Error)
}
return decodeData[uploadResultBody](t, body).Path
}
func uploadBaseName(path string) string {
return path[strings.LastIndex(path, "/")+1:]
}
type teamCreatedBody struct {
ID int64 `json:"id"`
}
// createTeamWithLogo 建一个挂 logo 的单位,返回 id;logo 引用点是 teams.logo_path。
func createTeamWithLogo(t *testing.T, env *testEnv, client *http.Client, code, name, logo string) int64 {
t.Helper()
status, body := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": code, "name": name, "logoPath": logo, "active": true,
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, body.Error)
}
return decodeData[teamCreatedBody](t, body).ID
}
// putImageRef 改一处赛事设置的图片引用(path 传空串表示清除)。
func putImageRef(t *testing.T, env *testEnv, client *http.Client, key, path string) {
t.Helper()
event := map[string]any{}
display := map[string]any{}
switch key {
case "event.logo_path":
event["logoPath"] = path
case "display.logo":
display["logo"] = path
case "display.background":
display["background"] = path
default:
t.Fatalf("未知的引用点 %s", key)
}
status, body := env.do(client, http.MethodPut, "/api/settings",
// requestId 必须带上目标值:服务端按 body.requestId 做幂等回放,
// 「先设成 A 再清空」用同一个 id 会让第二次直接回放第一次的响应,清空根本没落库。
map[string]any{"event": event, "display": display, "requestId": "req-ref-" + key + "-" + path}, true)
if status != http.StatusOK {
t.Fatalf("设置 %s 应 200,得到 %d %+v", key, status, body.Error)
}
}
func listOrphans(t *testing.T, env *testEnv, client *http.Client) uploadOrphansBody {
t.Helper()
status, body := env.do(client, http.MethodGet, "/api/uploads/orphans", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("列孤儿图片应 200,得到 %d %+v", status, body.Error)
}
return decodeData[uploadOrphansBody](t, body)
}
// 孤儿清单只列「没有任何引用点用到」的图片:赛事会标与单位图标都要被认出来。
func TestListUploadOrphansOnlyUnreferenced(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
eventLogo := uploadOne(t, env, client)
teamLogo := uploadOne(t, env, client)
free := uploadOne(t, env, client)
if names := uploadsDirEntries(t, uploadsDir); len(names) != 3 {
t.Fatalf("应先落盘 3 张图片,实际 %v", names)
}
putImageRef(t, env, client, "event.logo_path", eventLogo)
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
orphans := listOrphans(t, env, client)
if orphans.Total != 1 || len(orphans.Items) != 1 {
t.Fatalf("只应剩 1 张孤儿,得到 total=%d items=%+v", orphans.Total, orphans.Items)
}
item := orphans.Items[0]
if item.Name != uploadBaseName(free) {
t.Fatalf("孤儿应是未被引用的那张,得到 %q,期望 %q", item.Name, uploadBaseName(free))
}
if item.URL != free {
t.Fatalf("孤儿 URL = %q,期望 %q", item.URL, free)
}
if item.Size != int64(len(pngContent())) {
t.Fatalf("孤儿大小 = %d,期望 %d", item.Size, len(pngContent()))
}
if _, err := time.Parse(time.RFC3339, item.ModifiedAt); err != nil {
t.Fatalf("修改时间应是 RFC3339,得到 %q(%v)", item.ModifiedAt, err)
}
if orphans.TotalBytes != item.Size {
t.Fatalf("总占用 = %d,期望 %d", orphans.TotalBytes, item.Size)
}
// 把图配到大屏会标上,它就不再是孤儿。
putImageRef(t, env, client, "display.logo", free)
if after := listOrphans(t, env, client); after.Total != 0 || len(after.Items) != 0 {
t.Fatalf("全部被引用后应没有孤儿,得到 %+v", after)
}
}
// 没有任何上传时是空清单而不是错误,便于前端直接渲染空态。
func TestListUploadOrphansEmptyWhenNoUploads(t *testing.T) {
env, _, client := uploadAdminEnv(t)
orphans := listOrphans(t, env, client)
if orphans.Total != 0 || orphans.TotalBytes != 0 || orphans.Items == nil {
t.Fatalf("空目录应回空数组而不是 null:%+v(items=%#v)", orphans, orphans.Items)
}
}
// 删除被引用的图片必须 409,并把「被谁引用」写进提示;被引用的文件不能被删掉。
func TestDeleteUploadRejectsReferenced(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
eventLogo := uploadOne(t, env, client)
displayLogo := uploadOne(t, env, client)
background := uploadOne(t, env, client)
teamLogo := uploadOne(t, env, client)
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
putImageRef(t, env, client, "event.logo_path", eventLogo)
putImageRef(t, env, client, "display.logo", displayLogo)
putImageRef(t, env, client, "display.background", background)
cases := []struct {
path string
label string
}{
{eventLogo, "赛事会标"},
{displayLogo, "大屏会标"},
{background, "大屏背景图"},
{teamLogo, "单位「第一中学」的单位图标"},
}
for _, c := range cases {
status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(c.path), nil, true)
if status != http.StatusConflict {
t.Fatalf("删除被引用的 %s 应 409,得到 %d %+v", c.path, status, body.Error)
}
if body.Error == nil || !strings.Contains(body.Error.Message, c.label) {
t.Fatalf("409 提示应说明引用位置 %q:%+v", c.label, body.Error)
}
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(c.path))); err != nil {
t.Fatalf("被引用的图片不能被删除 %s: %v", c.path, err)
}
}
// 清掉引用后同一张图就能删掉,文件确实消失。
putImageRef(t, env, client, "event.logo_path", "")
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(eventLogo), nil, true); status != http.StatusOK {
t.Fatalf("不再被引用的图片应可删除,得到 %d %+v", status, body.Error)
}
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(eventLogo))); !os.IsNotExist(err) {
t.Fatalf("删除后文件应消失: %v", err)
}
// 名字合法但文件不存在 → 404;非法文件名一律 404,不做任何删除尝试。
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/0123456789abcdef0123456789abcdef.png", nil, true); status != http.StatusNotFound {
t.Fatalf("删除不存在的图片应 404,得到 %d", status)
}
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/notes.txt", nil, true); status != http.StatusNotFound {
t.Fatalf("非图片扩展名应 404,得到 %d", status)
}
}
// 被「彻底删除」(可恢复)的单位仍算引用:恢复后图标必须还在,所以不能当孤儿删掉。
func TestUploadOrphansCountsHiddenTeam(t *testing.T) {
env, _, client := uploadAdminEnv(t)
logo := uploadOne(t, env, client)
teamID := createTeamWithLogo(t, env, client, "T1", "第一中学", logo)
if status, body := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", teamID), nil, true); status != http.StatusOK {
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, body.Error)
}
if orphans := listOrphans(t, env, client); orphans.Total != 0 {
t.Fatalf("隐藏单位的 logo 仍算被引用,不应进孤儿清单:%+v", orphans.Items)
}
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(logo), nil, true); status != http.StatusConflict {
t.Fatalf("隐藏单位的 logo 应拒删,得到 %d %+v", status, body.Error)
}
}
// 批量删除只动点名的文件:被引用的、不存在的、非法的逐条回报且不动文件。
func TestDeleteUploadOrphansBatch(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
used := uploadOne(t, env, client)
orphanA := uploadOne(t, env, client)
orphanB := uploadOne(t, env, client)
createTeamWithLogo(t, env, client, "T1", "第一中学", used)
names := []string{uploadBaseName(orphanA), uploadBaseName(orphanB), uploadBaseName(used), "0123456789abcdef0123456789abcdef.png", "../config.yaml"}
status, body := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
map[string]any{"names": names, "requestId": "req-orphan-del-1"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("批量删除应 200,得到 %d %+v", status, body.Error)
}
result := decodeData[uploadDeleteBody](t, body)
if len(result.Deleted) != 2 || result.Deleted[0] != uploadBaseName(orphanA) || result.Deleted[1] != uploadBaseName(orphanB) {
t.Fatalf("只应删掉两张孤儿,得到 %+v", result.Deleted)
}
if len(result.Failed) != 3 {
t.Fatalf("应有 3 条失败回报,得到 %+v", result.Failed)
}
reasonOf := func(name string) string {
for _, item := range result.Failed {
if item.Name == name {
return item.Reason
}
}
t.Fatalf("失败回报里缺少 %s:%+v", name, result.Failed)
return ""
}
if reason := reasonOf(uploadBaseName(used)); !strings.Contains(reason, "仍被引用") || !strings.Contains(reason, "单位「第一中学」") {
t.Fatalf("被引用条目的原因应点名引用位置,得到 %q", reason)
}
if reason := reasonOf("0123456789abcdef0123456789abcdef.png"); !strings.Contains(reason, "文件不存在") {
t.Fatalf("缺失文件应报不存在,得到 %q", reason)
}
if reason := reasonOf("../config.yaml"); !strings.Contains(reason, "文件名非法") {
t.Fatalf("非法文件名应被拒,得到 %q", reason)
}
for _, name := range []string{uploadBaseName(orphanA), uploadBaseName(orphanB)} {
if _, err := os.Stat(filepath.Join(uploadsDir, name)); !os.IsNotExist(err) {
t.Fatalf("孤儿 %s 应已删除: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(used))); err != nil {
t.Fatalf("被引用的图片不能被批量删除: %v", err)
}
var audits int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.delete'`).Scan(&audits); err != nil {
t.Fatalf("查询删除审计: %v", err)
}
if audits != 1 {
t.Fatalf("批量删除应写一条 uploads.delete 审计,得到 %d", audits)
}
// 空清单直接拒绝,不能让「没传 names」变成「什么都没删但显示成功」。
if status, _ := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
map[string]any{"names": []string{}, "requestId": "req-orphan-del-2"}, true); status != http.StatusBadRequest {
t.Fatalf("空 names 应 400,得到 %d", status)
}
}
// 删除与清理是系统级资源管理:guest 401、recorder 403。
func TestUploadDeleteRequiresAdmin(t *testing.T) {
env, uploadsDir, admin := uploadAdminEnv(t)
orphan := uploadBaseName(uploadOne(t, env, admin))
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
// 各自用对应的方法:GET 那条走的是 /api/uploads/{name} 之外的兜底路由,用错方法量到的是 404 而不是权限。
if status, _ := env.do(guest, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读孤儿清单应 403,得到 %d", status)
}
if status, _ := env.do(guest, http.MethodDelete, "/api/uploads/"+orphan, nil, false); status != http.StatusUnauthorized {
t.Fatalf("guest 删图片应 401,得到 %d", status)
}
if status, _ := env.do(guest, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}}, false); status != http.StatusUnauthorized {
t.Fatalf("guest 批量删孤儿应 401,得到 %d", status)
}
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读孤儿清单应 403,得到 %d", status)
}
if status, _ := env.do(recorder, http.MethodDelete, "/api/uploads/"+orphan, nil, true); status != http.StatusForbidden {
t.Fatalf("recorder 删图片应 403,得到 %d", status)
}
if status, _ := env.do(recorder, http.MethodPost, "/api/uploads/orphans/delete",
map[string]any{"names": []string{orphan}, "requestId": "req-orphan-rec"}, true); status != http.StatusForbidden {
t.Fatalf("recorder 批量删孤儿应 403,得到 %d", status)
}
// 权限不足的请求不能有任何副作用:那张孤儿必须还在。
if _, err := os.Stat(filepath.Join(uploadsDir, orphan)); err != nil {
t.Fatalf("权限不足的删除请求不该动文件: %v", err)
}
}
// 删除接口不能靠路径穿越打到 uploads 之外:uploads 同级的图片扩展名文件必须毫发无损。
func TestDeleteUploadRejectsTraversal(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
projectDir := filepath.Dir(uploadsDir)
const secret = "outside-secret"
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatalf("创建项目目录: %v", err)
}
outside := filepath.Join(projectDir, "secret.png")
if err := os.WriteFile(outside, []byte(secret), 0o644); err != nil {
t.Fatalf("写 uploads 之外的文件: %v", err)
}
paths := []string{
"/api/uploads/..%2Fsecret.png",
"/api/uploads/%2e%2e%2Fsecret.png",
"/api/uploads/a%2Fb.png",
}
for _, path := range paths {
status, _ := env.do(client, http.MethodDelete, path, nil, true)
if status == http.StatusOK {
t.Fatalf("%s 不应删成功", path)
}
if body, err := os.ReadFile(outside); err != nil || string(body) != secret {
t.Fatalf("uploads 之外的文件被动了 %s: %v %q", path, err, body)
}
}
}
+9 -1
View File
@@ -33,6 +33,13 @@ func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any,
if len(in.Results) == 0 {
return nil, badRequest("results 不能为空", nil)
}
// 计分引擎不读 points_override,落库只会造成「设置成功但毫无效果」。
// 这里直接拒绝并指向正确做法(积分调整),不做静默丢弃。
for i, it := range in.Results {
if it.PointsOverride != nil {
return nil, badRequest("pointsOverride 已不再支持:该值不会被计分引擎读取,请改用「积分调整」为该单位/运动员加减分", i)
}
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
saved, err := s.upsertDraft(r.Context(), in.EventID, middlewareUserID(r), in.Results)
if err != nil {
@@ -298,7 +305,8 @@ func (s *server) listAuditLogsHandler(w http.ResponseWriter, r *http.Request) (a
page, size := pageQuery(r)
entityType := strings.TrimSpace(r.URL.Query().Get("entityType"))
userID, _ := int64Query(r, "userId")
return s.listAuditLogs(r.Context(), page, size, entityType, userID)
action := strings.TrimSpace(r.URL.Query().Get("action"))
return s.listAuditLogs(r.Context(), page, size, entityType, userID, action)
}
func (s *server) backupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
+12
View File
@@ -177,3 +177,15 @@ type resetUserPasswordInput struct {
Password string `json:"password"`
RequestID string `json:"requestId"`
}
// restoreBackupPayload 是 POST /api/backup/restore 的请求体。
//
// File 只接受备份目录里的裸文件名(不接受路径);Kind 是**要覆盖的那份库**
// (备份列表里的 dbKind)——安全副本的性质是 safety,但它同样属于某一份库,
// 拿它退回上一个时间点是正当操作,所以这里仍只认 system / event。
// 服务端再按文件名校验一遍(对不上直接拒绝):恢复错库的代价是两份数据一起完蛋。
type restoreBackupPayload struct {
File string `json:"file"`
Kind string `json:"kind"`
RequestID string `json:"requestId"`
}
@@ -0,0 +1,71 @@
package httpapi_test
import (
"net/http"
"strings"
"testing"
)
// points_override 落库但引擎不读,属于「设置成功但毫无效果」。
// 写入侧必须 400 并指向正确做法(积分调整);库里也不能留下任何非空值。
func TestSaveDraftRejectsPointsOverride(t *testing.T) {
env, ids, client := recorderWithSeed(t)
override := 7.5
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok", "pointsOverride": override},
},
}, true)
if status != http.StatusBadRequest {
t.Fatalf("带 pointsOverride 的保存应 400,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "积分调整") {
t.Fatalf("400 提示应指向「积分调整」,得到 %+v", env2.Error)
}
var stored int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM results WHERE points_override IS NOT NULL`).Scan(&stored); err != nil {
t.Fatalf("统计 points_override: %v", err)
}
if stored != 0 {
t.Fatalf("被拒绝的请求不应落库任何 points_override,实际 %d 条", stored)
}
// 不带 pointsOverride 的保存照常成功。
status, env2 = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("不带 pointsOverride 的保存应 200,得到 %d %+v", status, env2.Error)
}
}
// 历史数据里若真带着 points_override,计分环节必须显式报错而不是照常算出别的分。
func TestPublishRejectsLegacyPointsOverride(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
}
env.exec(t, `UPDATE results SET points_override = 3 WHERE entry_id = ?`, ids.entryA)
status, env2 = env.do(client, http.MethodPost, "/api/results/publish",
map[string]any{"eventId": ids.event}, true)
if status != http.StatusBadRequest {
t.Fatalf("带 points_override 的成绩发布应 400,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "积分调整") {
t.Fatalf("400 提示应指向「积分调整」,得到 %+v", env2.Error)
}
}
+33
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"context"
"database/sql"
"errors"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
@@ -39,6 +40,13 @@ func (s *server) broadcastChanges(next http.Handler) http.Handler {
ctx := context.WithoutCancel(r.Context())
changes, err := s.realtime.LoadChanges(ctx, before, 0)
if err != nil {
if dbClosed(err) {
// 恢复流程会在请求末尾关掉目标库再换文件,广播中间件在这之后才读 change_log,
// 必然撞上「库已关闭」。这是停机动作的一部分,不是故障——
// 记 ERROR 只会让现场以为恢复把系统搞坏了。降级成 Debug,其余错误照旧。
s.logger.Debug("跳过实时推送:库已关闭(恢复/重启中)", "err", err)
return
}
s.logger.Error("读取实时变更失败", "err", err)
return
}
@@ -48,10 +56,35 @@ func (s *server) broadcastChanges(next http.Handler) http.Handler {
})
}
// errDBClosed 是 database/sql 在连接池已关闭时返回的错误。
//
// 标准库里这个错误是**未导出**的(database/sql 的 errDBClosed),拿不到它的变量,
// 只能按原文认;这里独立声明一份,比对时逐层走错误链看有没有同文本的那一层。
var errDBClosed = errors.New("sql: database is closed")
// dbClosed 判定错误是否只是「库已经被关掉」。
//
// 只认原文完全相等的那一层(含被 %w 包过的),不按子串匹配:
// 别的读库失败(锁超时、SQL 写错、库损坏)都得继续当 ERROR 报出来,
// 不能被这条静默规则顺手吞掉。
func dbClosed(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if e.Error() == errDBClosed.Error() {
return true
}
}
return false
}
// changeVersion 读取 change_log 当前最大 id;失败时调用方跳过本次推送。
func (s *server) changeVersion(ctx context.Context) (int64, bool) {
var v sql.NullInt64
if err := s.ev.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
if dbClosed(err) {
// 同 broadcastChanges:库已关闭是恢复/重启的中间状态,不是故障。
s.logger.Debug("跳过变更版本:库已关闭(恢复/重启中)", "err", err)
return 0, false
}
s.logger.Error("读取变更版本失败", "err", err)
return 0, false
}
@@ -0,0 +1,170 @@
package httpapi
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
/**
* 恢复流程会在请求末尾关掉目标库、换掉文件,随后进程才优雅退出。
* 广播中间件是在 handler 返回之后读 change_log 的,必然撞上「sql: database is closed」——
* 那不是故障,是一条 ERROR 却在现场最紧张的时刻出现,会被当成「恢复把系统搞坏了」。
*
* 这里钉住两条口径:库已关闭 → 只留 Debug;其它读库失败 → 照旧 ERROR,不许被静默吞掉。
*/
// stubRealtime 只实现广播中间件用到的那两个方法。
type stubRealtime struct {
loadErr error
}
func (s *stubRealtime) Broadcast([]realtime.Change) {}
func (s *stubRealtime) LoadChanges(context.Context, int64, int) ([]realtime.Change, error) {
return nil, s.loadErr
}
func (s *stubRealtime) Instances() []realtime.Instance { return nil }
func captureLogs() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
func openSQLiteAt(t *testing.T, path string, migrate bool) *sql.DB {
t.Helper()
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开 %s: %v", path, err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if migrate {
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
}
return db
}
// closedDBError 拿一个真正的「sql: database is closed」。
// 标准库里这个错误是未导出的(database/sql 的 errDBClosed),只能打开一个库再关掉、
// 然后查询它,让驱动自己把它交出来。
func closedDBError(t *testing.T) error {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "closed.db")+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开库: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("关闭库: %v", err)
}
var one int
return db.QueryRow("SELECT 1").Scan(&one)
}
// 库已关闭不是故障:changeVersion 只留 Debug,不出 ERROR。
func TestChangeVersionClosedDBIsNotLoggedAsError(t *testing.T) {
logger, logs := captureLogs()
db := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), false)
if err := db.Close(); err != nil {
t.Fatalf("关闭赛事库: %v", err)
}
s := &server{logger: logger, ev: db}
if _, ok := s.changeVersion(context.Background()); ok {
t.Fatal("已关闭的库不该给出变更版本")
}
if strings.Contains(logs.String(), "level=ERROR") {
t.Errorf("库已关闭不该记 ERROR:%s", logs.String())
}
if !strings.Contains(logs.String(), "库已关闭") {
t.Errorf("应留下一条 Debug 便于排查:%s", logs.String())
}
}
// 真的读不通(这里是表都不存在)仍然要记 ERROR,不能被「库已关闭」这条规则顺手吞掉。
func TestChangeVersionOtherErrorsAreStillLogged(t *testing.T) {
logger, logs := captureLogs()
// 没跑过迁移的库:没有 change_log 表。
db := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), false)
s := &server{logger: logger, ev: db}
if _, ok := s.changeVersion(context.Background()); ok {
t.Fatal("查不到 change_log 时不该给出变更版本")
}
if !strings.Contains(logs.String(), "level=ERROR") || !strings.Contains(logs.String(), "读取变更版本失败") {
t.Errorf("真失败仍要记 ERROR:%s", logs.String())
}
}
// 广播中间件:写请求成功后 LoadChanges 撞上「库已关闭」→ 不记 ERROR。
func TestBroadcastChangesClosedDBIsNotLoggedAsError(t *testing.T) {
logger, logs := captureLogs()
ev := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), true)
// 按 realtime 包的包装方式裹一层,确认 errors 链上的「库已关闭」也认得出。
loadErr := fmt.Errorf("读取增量变更: %w", closedDBError(t))
s := &server{logger: logger, ev: ev, realtime: &stubRealtime{loadErr: loadErr}}
h := s.broadcastChanges(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/teams", nil))
if strings.Contains(logs.String(), "level=ERROR") {
t.Errorf("库已关闭不该记 ERROR:%s", logs.String())
}
if !strings.Contains(logs.String(), "库已关闭") {
t.Errorf("应留下一条 Debug 便于排查:%s", logs.String())
}
}
// 广播中间件:别的读库失败(磁盘 I/O、SQL 写错…)照旧记 ERROR。
func TestBroadcastChangesOtherErrorsAreStillLogged(t *testing.T) {
logger, logs := captureLogs()
ev := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), true)
s := &server{logger: logger, ev: ev, realtime: &stubRealtime{loadErr: errors.New("disk I/O error")}}
h := s.broadcastChanges(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/teams", nil))
if !strings.Contains(logs.String(), "level=ERROR") || !strings.Contains(logs.String(), "读取实时变更失败") {
t.Errorf("真失败仍要记 ERROR:%s", logs.String())
}
}
// dbClosed 只认原文完全相等的那一层:子串、相似文案都不算。
func TestDbClosedMatchesOnlyExactMessage(t *testing.T) {
if !dbClosed(closedDBError(t)) {
t.Error("真正的「sql: database is closed」应被认出")
}
if !dbClosed(fmt.Errorf("读取增量变更: %w", closedDBError(t))) {
t.Error("被 %w 包过一层也要认出")
}
for _, err := range []error{
nil,
errors.New("database is closed"),
errors.New("sql: database is closed (something else)"),
errors.New("sql: database is locked"),
errors.New("disk I/O error"),
} {
if dbClosed(err) {
t.Errorf("%v 不该被当成「库已关闭」", err)
}
}
}
+77
View File
@@ -341,6 +341,83 @@ func (s *server) reviveTeam(ctx context.Context, id int64) error {
return nil
}
// teamRestore 是恢复接口的回报。
//
// Restored 单独报出来,是因为「本来就没被隐藏」与「这一次真的恢复了」在界面上是两句话,
// 审计也只该为后者留痕:重复点恢复不该在审计里堆一串什么都没发生的记录。
type teamRestore struct {
// Before 只给审计用(被隐藏时的样子),不进响应体——响应体回答的是「它现在长什么样」。
Before teamDTO `json:"-"`
Team teamDTO `json:"team"`
Restored bool `json:"restored"`
}
// restoreTeam 把「彻底删除」(隐藏)的单位放回界面,是操作员显式点的那个恢复入口。
//
// 与 reviveTeam(导入命中编码时的顺带复活)的区别只有两点,语义本身完全一致:
// - 先确认它真的被隐藏:没被隐藏就按幂等成功返回现状,绝不顺手把「停用」改成启用;
// - 编码已被另一个可见单位占用时明确报 409,不让库里出现两个同 code 的可见单位。
//
// 隐藏时只动了 deleted_at / active 两列,报名、成绩、流水、人工调整、纪录一行未动,
// 所以这里同样不重算、不推榜单:成功后榜单与 change_log 应逐字节不变(有测试钉着)。
func (s *server) restoreTeam(ctx context.Context, id int64) (teamRestore, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamRestore{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamRestore{}, err
}
if before.DeletedAt == nil {
return teamRestore{Before: before, Team: before, Restored: false}, nil
}
// 编码是唯一索引,正常不可能撞上;但有人绕过导入路径手工改库时「已隐藏的 A」与
// 「新建的 A」会同时存在,此时恢复会造出两个同 code 的可见单位,榜单与导出都会指错行。
var dup int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM teams WHERE code = ? AND id <> ? AND deleted_at IS NULL`,
before.Code, id).Scan(&dup); err != nil {
return teamRestore{}, internal(err)
}
if dup > 0 {
return teamRestore{}, conflict("单位编码 " + before.Code + " 已被另一个可见单位占用,请先处理那个单位再恢复")
}
if _, err := tx.ExecContext(ctx,
`UPDATE teams SET deleted_at = NULL, active = 1 WHERE id = ?`, id); err != nil {
return teamRestore{}, internal(err)
}
after, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamRestore{}, err
}
if err := tx.Commit(); err != nil {
return teamRestore{}, internal(err)
}
return teamRestore{Before: before, Team: after, Restored: true}, nil
}
// restoreTeamHandler 处理 POST /api/teams/{id}/restore:把「彻底删除」的单位放回界面。
//
// 权限与彻底删除同档(recorder)——能隐藏的人就该能恢复,不额外收紧。
func (s *server) restoreTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
result, err := s.restoreTeam(r.Context(), id)
if err != nil {
return nil, err
}
// 只在真的改了库时记审计:重复点恢复、或对没隐藏的单位调用,都只是幂等回放,不是事件。
if result.Restored {
s.audit(r.Context(), "team.restore", "team", id, result.Before, result.Team)
}
return result, nil
}
// ---------- athletes ----------
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
-119
View File
@@ -675,125 +675,6 @@ func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]e
return out, nil
}
func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) {
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return entryDTO{}, notFound("报名项不存在")
}
if err != nil {
return entryDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) {
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
}
status, err := normalizeEntryStatus(in.Status)
if err != nil {
return entryDTO{}, err
}
if err := s.checkCategoryInEvent(ctx, in.EventID, in.CategoryID); err != nil {
return entryDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark))
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, id)
}
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
// 先确认报名项存在:小项不可改,categoryId 要拿它的 event_id 来校验。
// 这条查询同时守住了 404 语义,别让后面的校验把它降级成 400。
eventID, err := s.entryEventID(ctx, id)
if err != nil {
return entryDTO{}, err
}
status, err := normalizeEntryStatus(in.Status)
if err != nil {
return entryDTO{}, err
}
if err := s.checkCategoryInEvent(ctx, eventID, in.CategoryID); err != nil {
return entryDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark), id)
if isUniqueViolation(err) {
return entryDTO{}, conflict("该报名项已存在")
}
if isForeignKeyViolation(err) {
return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil)
}
if err != nil {
return entryDTO{}, internal(err)
}
if err := requireChanges(res, "报名项不存在"); err != nil {
return entryDTO{}, err
}
return s.getEntry(ctx, id)
}
// entryEventID 读报名项所属小项:小项不可改,更新组别时要以它为准做归属校验。
// 找不到时给 404,调用方(更新)原本也靠 requireChanges 给出同样的语义。
func (s *server) entryEventID(ctx context.Context, id int64) (int64, error) {
var eventID int64
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, id).Scan(&eventID)
if errors.Is(err, sql.ErrNoRows) {
return 0, notFound("报名项不存在")
}
if err != nil {
return 0, internal(err)
}
return eventID, nil
}
// checkCategoryInEvent 拦住「A 小项的报名挂在 B 小项的组别上」。
//
// event_categories 只有 (event_id, name) 唯一约束,entries 也没有 (event_id, category_id)
// 复合外键,所以外键只能保证组别存在、保证不了同属一个小项。组别不存在时放行,
// 交给 INSERT/UPDATE 的外键报错,保持原有「...不存在」的 400 文案不变。
func (s *server) checkCategoryInEvent(ctx context.Context, eventID, categoryID int64) error {
var catEvent int64
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM event_categories WHERE id = ?`, categoryID).Scan(&catEvent)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil
case err != nil:
return internal(err)
case catEvent != eventID:
return badRequest("categoryId 不属于该小项", categoryID)
}
return nil
}
func (s *server) deleteEntry(ctx context.Context, id int64) error {
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
if err != nil {
return internal(err)
}
return requireChanges(res, "报名项不存在")
}
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
const maxEntryHeat = 999
+14 -1
View File
@@ -1064,7 +1064,10 @@ func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason st
// ---------- audit logs ----------
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) {
// listAuditLogs 支持 entityType / userId / action 三个过滤条件,任意组合。
// action 按子串模糊匹配:现场找的是「某类动作」(如 team.、result.publish),
// 精确等值会逼操作员背全名。三个条件都进同一条 WHERE,组合时是 AND。
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64, action string) (pageResult[auditLogDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if entityType != "" {
@@ -1075,6 +1078,11 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s
where = append(where, "user_id = ?")
args = append(args, userID)
}
if action != "" {
// 转义 % / _ / \ 让它们按字面量匹配,避免用户输入被当成通配符。
where = append(where, `action LIKE ? ESCAPE '\'`)
args = append(args, "%"+escapeLike(action)+"%")
}
clause := " WHERE " + joinAnd(where)
var total int
@@ -1117,6 +1125,11 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s
return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// escapeLike 把 LIKE 的通配符转义为字面量,配合 ESCAPE '\' 使用。
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// ---------- backup ----------
// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。
+2
View File
@@ -309,6 +309,8 @@ func mapDomainError(err error) error {
errors.Is(err, domain.ErrUnknownBasis),
errors.Is(err, domain.ErrUnknownLedgerOp):
return stateInvalid(err.Error())
case errors.Is(err, domain.ErrPointsOverrideUnsupported):
return badRequest("pointsOverride 已不再支持,请改用「积分调整」为该单位/运动员加减分", err.Error())
case errors.Is(err, domain.ErrProfileNotFound):
return badRequest("计分通道引用的积分模板不存在", err.Error())
case errors.Is(err, domain.ErrLedgerAlreadyVoided):
+11
View File
@@ -208,6 +208,8 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler))
mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler))
mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler))
// 「彻底删除」是假删除(只标记不可见),这条把标记撤掉、单位回到界面。权限与删除同档。
mux.HandleFunc("POST /api/teams/{id}/restore", s.write(auth.RoleRecorder, s.restoreTeamHandler))
mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler))
mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler))
@@ -279,6 +281,8 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
// 恢复是停机动作:校验 + 拍安全副本 + 换文件后优雅重启,详见 restoreBackupHandler。
mux.HandleFunc("POST /api/backup/restore", s.write(auth.RoleAdmin, s.restoreBackupHandler))
// 用户管理(系统配置,仅 admin;guest 读 403、写 401)
mux.HandleFunc("GET /api/users", s.read(auth.RoleAdmin, s.listUsersHandler))
@@ -295,6 +299,13 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
// 图片的删除与孤儿清理(admin,与字体删除同一口径):上传是录入员的日常操作,
// 删文件是系统级资源管理。删除接口复用 uploadContentType 的名字校验与防穿越;
// 孤儿清单只列「没有任何引用点用到」的图片,批量删除只动请求里点名的文件。
mux.HandleFunc("DELETE /api/uploads/{name}", s.writeVoid(auth.RoleAdmin, s.deleteUploadHandler))
mux.HandleFunc("GET /api/uploads/orphans", s.read(auth.RoleAdmin, s.listUploadOrphansHandler))
mux.HandleFunc("POST /api/uploads/orphans/delete", s.write(auth.RoleAdmin, s.deleteUploadsHandler))
// 字体:4 款内置字体(前端 @font-face)由 GET /api/fonts 列出,上传的落
// <项目目录>/uploads/fonts/,随赛事包导出;静态访问同样是匿名放行——大屏的
// @font-face 不带会话。字体目录比图片多一层,静态路由必须单独注册。
@@ -0,0 +1,296 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
)
// restoreData 是恢复接口(POST /api/teams/{id}/restore)的响应体。
type restoreData struct {
Team struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName *string `json:"shortName"`
LogoPath *string `json:"logoPath"`
Color *string `json:"color"`
SortOrder int `json:"sortOrder"`
Active bool `json:"active"`
DeletedAt *string `json:"deletedAt"`
} `json:"team"`
Restored bool `json:"restored"`
}
// standingsSnapshot 取积分榜原始响应体(json.RawMessage 的原文),供「逐字节不变」的比对。
func standingsSnapshot(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
}
return string(env2.Data)
}
// maxChangeLogID 取 change_log 的最大 id:它是实时推送的 version,只要它没动,
// 就说明这次操作既没重算也没推榜单。
func maxChangeLogID(t *testing.T, env *testEnv) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&id); err != nil {
t.Fatalf("读 change_log 最大值: %v", err)
}
return id
}
// restoreTeamReq 调恢复接口(写请求要带 CSRF 头)。
func restoreTeamReq(t *testing.T, env *testEnv, client *http.Client, id int64) (int, envelope) {
t.Helper()
return env.do(client, http.MethodPost, fmt.Sprintf("/api/teams/%d/restore", id), nil, true)
}
// findEntry 在报名列表里找某个报名项。
func findEntry(rows []map[string]any, id int64) map[string]any {
for _, row := range rows {
if row["id"] == float64(id) {
return row
}
}
return nil
}
// countAudit 统计某个动作的审计条数(审计在响应写完后才落库,所以调用前要给它一点时间)。
func countAudit(t *testing.T, env *testEnv, action string, entityID int64) int {
t.Helper()
return countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ? AND entity_id = ?`, action, entityID)
}
// 恢复的核心:单位回到默认列表,且报名、成绩、流水一行没动——榜单与 change_log 逐字节不变。
func TestRestoreTeamBringsHiddenTeamBack(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "team-restore",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
// 人工调整与纪录也一起造出来:恢复后它们应原样还在。
adj := env.insert(t, `INSERT INTO point_adjustments (team_id, delta, category, reason)
VALUES (?, 5, 'bonus', 'A 的加分')`, ids.teamA)
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points)
VALUES (?, ?, 'adjustment', ?, 5)`, ids.teamA, ids.event, adj)
standingsBefore := standingsSnapshot(t, env, client)
logBefore := maxChangeLogID(t, env)
entriesBefore := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA)
ledgerBefore := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA)
// 先彻底删除:默认列表消失,但榜单与 change_log 都不该动。
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
}
if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) != nil {
t.Fatalf("前置条件:彻底删除后默认列表不该有它")
}
if got := standingsSnapshot(t, env, client); got != standingsBefore {
t.Fatalf("彻底删除不该改榜单:\n之前 %s\n之后 %s", standingsBefore, got)
}
if got := maxChangeLogID(t, env); got != logBefore {
t.Fatalf("彻底删除不该写 change_log:之前 %d,之后 %d", logBefore, got)
}
// 恢复。
status, env2 := restoreTeamReq(t, env, client, ids.teamA)
if status != http.StatusOK || !env2.OK {
t.Fatalf("恢复应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[restoreData](t, env2)
if !result.Restored {
t.Fatalf("这次恢复真的改了库,restored 应为 true,得到 %+v", result)
}
// 与隐藏前完全等价:编码、全称、简称、排序都不该被这次操作碰到。
if result.Team.ID != ids.teamA || result.Team.Code != "A" || result.Team.Name != "第一分公司" ||
result.Team.SortOrder != 0 || result.Team.Active != true || result.Team.DeletedAt != nil {
t.Fatalf("恢复后的单位应与隐藏前等价(active=true、deletedAt=null),得到 %+v", result.Team)
}
if result.Team.ShortName == nil || *result.Team.ShortName != "一司" {
t.Fatalf("简称不该丢,得到 %+v", result.Team.ShortName)
}
// 库里:清空 deleted_at 并恢复 active。
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND deleted_at IS NULL AND active = 1`,
ids.teamA); n != 1 {
t.Fatalf("恢复应清空 deleted_at 并置 active = 1,得到 %d 条", n)
}
// 界面:默认列表里有它,非 admin 单查也不再是 404。
if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) == nil {
t.Fatalf("恢复后应回到默认列表")
}
if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/teams/%d", ids.teamA), nil, false); status != http.StatusOK {
t.Fatalf("恢复后 recorder 单查应 200,得到 %d", status)
}
// 报名与成绩照旧可见:报名行还在,成绩还挂着,单位名照样补得出来。
status, env3 := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
if status != http.StatusOK || !env3.OK {
t.Fatalf("读报名应 200,得到 %d %+v", status, env3.Error)
}
entry := findEntry(decodeData[[]map[string]any](t, env3), ids.entryA)
if entry == nil {
t.Fatalf("恢复后报名不该消失")
}
if entry["teamName"] != "第一分公司" {
t.Fatalf("恢复后报名上的单位名应照旧,得到 %+v", entry)
}
res, _ := entry["result"].(map[string]any)
if res == nil || res["resultValue"] != 11.58 || res["state"] != "published" {
t.Fatalf("恢复后已发布成绩应照旧可见,得到 %+v", entry["result"])
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA); n != entriesBefore {
t.Fatalf("报名不该被动:之前 %d 条,之后 %d 条", entriesBefore, n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA); n != ledgerBefore {
t.Fatalf("积分流水不该被动:之前 %d 条,之后 %d 条", ledgerBefore, n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments WHERE team_id = ?`, ids.teamA); n != 1 {
t.Fatalf("人工调整不该被动,得到 %d 条", n)
}
// 恢复不重算、不推榜单:榜单原文与 change_log 最大值都不变。
if got := standingsSnapshot(t, env, client); got != standingsBefore {
t.Fatalf("恢复不该改榜单:\n之前 %s\n之后 %s", standingsBefore, got)
}
if got := maxChangeLogID(t, env); got != logBefore {
t.Fatalf("恢复不该写 change_log / 推榜单:之前 %d,之后 %d", logBefore, got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
t.Fatalf("A 单位总分应还是 10 + 调整 5 = 15,得到 %v", got)
}
assertNoForeignKeyViolation(t, env)
// 审计留下 team.restore:before 是隐藏态、after 是恢复后的样子。
after := waitAudit(t, env, "team.restore", ids.teamA)
if !strings.Contains(after, `"active":true`) || !strings.Contains(after, `"deletedAt":null`) {
t.Fatalf("审计应记下恢复后的状态(active=true、deletedAt=null),得到 %s", after)
}
}
// 幂等:对没被隐藏的单位调恢复不报错、也不改库;尤其不能把「停用」顺手改成启用。
func TestRestoreTeamIsIdempotentAndDoesNotEnable(t *testing.T) {
env, ids, client := recorderWithSeed(t)
logBefore := maxChangeLogID(t, env)
// 情况一:一直可见、启用中的单位。
status, env2 := restoreTeamReq(t, env, client, ids.teamB)
if status != http.StatusOK || !env2.OK {
t.Fatalf("对没隐藏的单位调恢复应幂等成功,得到 %d %+v", status, env2.Error)
}
result := decodeData[restoreData](t, env2)
if result.Restored || !result.Team.Active || result.Team.DeletedAt != nil {
t.Fatalf("没隐藏时应回报 restored=false 且状态不变,得到 %+v", result)
}
// 幂等命中不写 change_log(没有重算、没有推送)。
if got := maxChangeLogID(t, env); got != logBefore {
t.Fatalf("幂等命中不该写 change_log:之前 %d,之后 %d", logBefore, got)
}
// 情况二:停用(软删除)是另一种状态,恢复不该成为「启用」的后门。
// 注意软删除本身会写一条 change_log(那是停用该推的),所以重新取一次基线。
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", ids.teamA), nil, true); status != http.StatusOK {
t.Fatalf("停用应 200,得到 %d %+v", status, env2.Error)
}
logBefore = maxChangeLogID(t, env)
status, env2 = restoreTeamReq(t, env, client, ids.teamA)
if status != http.StatusOK || !env2.OK {
t.Fatalf("对停用的单位调恢复应幂等成功,得到 %d %+v", status, env2.Error)
}
result = decodeData[restoreData](t, env2)
if result.Restored {
t.Fatalf("停用不是「被隐藏」,不该回报 restored=true,得到 %+v", result)
}
if result.Team.Active {
t.Fatalf("恢复不能把停用的单位改成启用,得到 %+v", result)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND active = 0 AND deleted_at IS NULL`,
ids.teamA); n != 1 {
t.Fatalf("停用状态应原样保留,得到 %d 条", n)
}
if got := maxChangeLogID(t, env); got != logBefore {
t.Fatalf("幂等命中不该写 change_log:之前 %d,之后 %d", logBefore, got)
}
// 幂等命中也不该留审计:什么都没发生,不该在审计里堆记录。
time.Sleep(200 * time.Millisecond)
if n := countAudit(t, env, "team.restore", ids.teamB); n != 0 {
t.Fatalf("幂等命中不该留 team.restore 审计,得到 %d 条", n)
}
if n := countAudit(t, env, "team.restore", ids.teamA); n != 0 {
t.Fatalf("对停用的单位调恢复不该留 team.restore 审计,得到 %d 条", n)
}
}
// 单位不存在:404 沿用仓库既有口径,与彻底删除一致。
func TestRestoreMissingTeamReturns404(t *testing.T) {
env, _, client := recorderWithSeed(t)
status, env2 := restoreTeamReq(t, env, client, 999999)
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "单位不存在" {
t.Fatalf("不存在的单位应 404 单位不存在,得到 %d %+v", status, env2.Error)
}
}
// 权限与彻底删除同档:guest 未登录 401,recorder 能删就能恢复(admin 也在 recorder 之上)。
func TestRestoreTeamPermissionMatchesPurge(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 未登录请求由 RequireWrite 处理(CSRF 中间件对 guest 直接放行),所以这里不带 CSRF 头。
guest, _ := env.client()
if status, env2 := env.do(guest, http.MethodPost, fmt.Sprintf("/api/teams/%d/restore", ids.teamA), nil, false); status != http.StatusUnauthorized {
t.Fatalf("未登录调恢复应 401,得到 %d %+v", status, env2.Error)
}
// recorder 能彻底删除,也就能恢复。
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
t.Fatalf("recorder 彻底删除应 200,得到 %d %+v", status, env2.Error)
}
if status, env2 := restoreTeamReq(t, env, client, ids.teamA); status != http.StatusOK {
t.Fatalf("recorder 恢复应 200,得到 %d %+v", status, env2.Error)
}
// admin 同样可以(角色包含关系,不额外收紧)。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
if status, env2 := restoreTeamReq(t, env, adminClient, ids.teamB); status != http.StatusOK {
t.Fatalf("admin 恢复应 200(幂等),得到 %d %+v", status, env2.Error)
}
}
// 恢复不制造重复编码:编码有全局唯一索引,同 code 的第二个单位(无论可见与否)根本插不进去,
// 所以恢复里那条「已被另一个可见单位占用」的 409 只是兜底(手工改库绕过索引时的最后一道闸)。
func TestRestoreTeamNeverDuplicatesCode(t *testing.T) {
env, ids, client := recorderWithSeed(t)
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
}
// 已隐藏的 A 仍占着 code = 'A':再建一个同 code 的单位必须撞唯一索引。
if _, err := env.ev.Exec(`INSERT INTO teams (code, name, sort_order, active) VALUES ('A', '冒名的 A', 9, 1)`); err == nil {
t.Fatalf("同 code 的第二个单位不该插得进去(应撞唯一索引)")
}
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A'`); n != 1 {
t.Fatalf("库里应只有一个 code = 'A' 的单位,得到 %d 个", n)
}
// 恢复后仍然只有一个,且是原来那一行。
if status, env2 := restoreTeamReq(t, env, client, ids.teamA); status != http.StatusOK {
t.Fatalf("恢复应 200,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A' AND deleted_at IS NULL`); n != 1 {
t.Fatalf("恢复后 code = 'A' 的可见单位应恰好一个,得到 %d 个", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A' AND id = ?`, ids.teamA); n != 1 {
t.Fatalf("恢复的应是原来那一行,得到 %d 条", n)
}
}
+2 -2
View File
@@ -183,8 +183,8 @@ func TestParseRejectsBadInput(t *testing.T) {
{"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation},
// aggregate 的汇总口径(avg/max/min/count)没有落库列,已从类型里移除;写了必须报错而不是静默丢弃。
{"通道 aggregate 口径已移除", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: aggregate, profile: team_top6, aggregate: avg }", 1), nil},
// 联队归属未实现,规则包里不允许出现相关配置。
{"联队归属未实现", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil},
// standings 只认已落库的键:多写的键必须报错,不能被静默丢弃。
{"standings 未知键", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil},
}
for _, tc := range cases {
+64 -1
View File
@@ -40,9 +40,11 @@ func TestMigrateSystem(t *testing.T) {
t.Fatalf("首次迁移: %v", err)
}
for _, table := range []string{"schema_migrations", "settings", "users", "sessions", "recent_projects"} {
for _, table := range []string{"schema_migrations", "settings", "users", "sessions"} {
assertTable(t, st.DB(), table)
}
// 0006 已把「最近打开的赛事」死表删掉;赛事记忆改用 active_project.txt。
assertNoTable(t, st.DB(), "recent_projects")
if got := queryString(t, st.DB(), `SELECT value FROM settings WHERE key = 'app.name'`); got == "" {
t.Error("默认系统设置 app.name 缺失")
@@ -57,6 +59,53 @@ func TestMigrateSystem(t *testing.T) {
}
}
// 旧库升级:包含 recent_projects 的库跑一次迁移后表消失,其余数据原样保留。
func TestMigrateDropsRecentProjectsOnLegacyDB(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "system.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("打开旧库: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
fsys, err := fs.Sub(migrations.SystemFS, "system")
if err != nil {
t.Fatalf("定位迁移目录: %v", err)
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
t.Fatalf("列出迁移: %v", err)
}
sort.Strings(names)
// 只应用 0006 之前的迁移,模拟已经上线过的旧库。
mustExec(t, db, createMigrationsTable)
for _, name := range names {
if name >= "0006_drop_recent_projects.sql" {
break
}
body, err := fs.ReadFile(fsys, name)
if err != nil {
t.Fatalf("读取迁移 %s: %v", name, err)
}
mustExec(t, db, string(body))
mustExec(t, db, `INSERT INTO schema_migrations(name) VALUES ('`+name+`')`)
}
assertTable(t, db, "recent_projects")
mustExec(t, db, `INSERT INTO recent_projects(project, name, db_path) VALUES ('p1', '旧赛事', 'event.db')`)
mustExec(t, db, `INSERT INTO settings(key, value, updated_at) VALUES ('legacy.marker', 'keep', datetime('now'))`)
if err := migrate(db, fsys, "system"); err != nil {
t.Fatalf("升级旧库: %v", err)
}
assertNoTable(t, db, "recent_projects")
if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'legacy.marker'`); got != "keep" {
t.Errorf("升级后旧数据应保留,legacy.marker = %q", got)
}
}
func TestMigrateEvent(t *testing.T) {
db := openEventDB(t)
@@ -361,6 +410,20 @@ func assertTable(t *testing.T, db *sql.DB, table string) {
}
}
func assertNoTable(t *testing.T, db *sql.DB, table string) {
t.Helper()
var n int
if err := db.QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
).Scan(&n); err != nil {
t.Fatalf("查询表 %s: %v", table, err)
}
if n != 0 {
t.Errorf("表 %s 应已被删除", table)
}
}
func mustExec(t *testing.T, db *sql.DB, query string) {
t.Helper()
if _, err := db.Exec(query); err != nil {
@@ -0,0 +1,3 @@
-- 「最近打开的赛事」已改用 <数据目录>/active_project.txt 记录,
-- recent_projects 表自创建起就没有任何读写路径,是死表;显式删除以免后来者误以为它仍在使用。
DROP TABLE IF EXISTS recent_projects;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Team, TeamInput } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
@@ -7,10 +7,20 @@ import ImageUploadField from '../system/ImageUploadField.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useAuthStore } from '../../stores/auth'
import { adminApi } from '../../api/client'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
const auth = useAuthStore()
/**
* 「已彻底删除」的单位默认不进列表,只有 admin 能用开关把它们翻出来恢复:
* `GET /api/teams?includeDeleted=1` 服务端只放 admin(其余角色 403),
* 所以这个开关对非 admin 既不显示也不生效。
*/
const isAdmin = computed(() => auth.user?.role === 'admin')
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,16}$/
const COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
@@ -47,22 +57,64 @@ const purgePending = ref(false)
/** 删除失败时的说明留在页面上:服务端那句原文(单位已不在等)比一行「操作失败」有用得多。 */
const purgeError = ref<string | null>(null)
/** 已彻底删除的单位:只在勾选「显示已彻底删除的单位」后单独拉取,不混进共享的 teams 列表。 */
const showDeleted = ref(false)
const hiddenTeams = ref<Team[]>([])
const hiddenLoading = ref(false)
const hiddenError = ref<string | null>(null)
const restoreOpen = ref(false)
const restoreTarget = ref<Team | null>(null)
const restorePending = ref(false)
const restoreError = ref<string | null>(null)
function emptyForm(): TeamForm {
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
}
/** 关键字匹配(代码 / 全称 / 简称),可见与已删除的行用同一套。 */
function matchesKeyword(team: Team, keyword: string): boolean {
if (keyword === '') return true
return (
team.code.toLowerCase().includes(keyword) ||
team.name.toLowerCase().includes(keyword) ||
(team.shortName ?? '').toLowerCase().includes(keyword)
)
}
const filtered = computed(() => {
const keyword = search.value.trim().toLowerCase()
return data.teams.value.filter((team) => {
const visible = data.teams.value.filter((team) => {
if (statusFilter.value === 'active' && !team.active) return false
if (statusFilter.value === 'inactive' && team.active) return false
if (keyword === '') return true
return (
team.code.toLowerCase().includes(keyword) ||
team.name.toLowerCase().includes(keyword) ||
(team.shortName ?? '').toLowerCase().includes(keyword)
)
return matchesKeyword(team, keyword)
})
// 已彻底删除的单位一律 active=0,按状态筛选会永远被滤掉,所以它们只跟搜索走。
return [...visible, ...hiddenTeams.value.filter((team) => matchesKeyword(team, keyword))]
})
async function loadHiddenTeams(): Promise<void> {
hiddenLoading.value = true
hiddenError.value = null
try {
const rows = await adminApi.client.teams.list({ includeDeleted: true })
hiddenTeams.value = rows.filter((team) => team.deletedAt != null)
} catch (caught) {
hiddenTeams.value = []
hiddenError.value = caught instanceof Error ? caught.message : '已彻底删除的单位加载失败'
} finally {
hiddenLoading.value = false
}
}
// 关掉开关就清空:留着上一份已删除名单,下一次打开会先闪一遍过期数据。
watch(showDeleted, (on) => {
if (!isAdmin.value) {
showDeleted.value = false
return
}
if (on) void loadHiddenTeams()
else hiddenTeams.value = []
})
const activeCount = computed(() => data.teams.value.filter((team) => team.active).length)
@@ -166,6 +218,25 @@ async function toggleActive(team: Team): Promise<void> {
}
}
/**
* 彻底删除确认框的说明。恢复入口藏在「显示已彻底删除的单位」开关后面,而那个开关
* 对非 admin 只显示不生效(服务端 403),所以必须在按下删除前把恢复要找管理员说清楚,
* 否则录入员删完在本机找不到任何恢复入口。admin 自己能恢复,不需要这句。
*/
const purgeDetails = computed(() =>
isAdmin.value
? [
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
'管理员可以勾选「显示已彻底删除的单位」,再从列表里把它恢复回来',
'只是想暂时不让它参加,请用「停用」',
]
: [
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
'彻底删除后本账号无法自行恢复,需要管理员在「显示已彻底删除的单位」里恢复',
'只是想暂时不让它参加,请用「停用」',
],
)
function askPurge(team: Team): void {
purgeTarget.value = team
purgeError.value = null
@@ -182,6 +253,8 @@ async function confirmPurge(): Promise<void> {
await data.purgeTeam(target.id)
toast.success(`已彻底删除「${target.name}」`)
purgeOpen.value = false
// 名单里若有这份已删除数据(开关开着),跟着刷新,行不会被留在表里当活单位看。
if (showDeleted.value) await loadHiddenTeams()
} catch (caught) {
// 服务端的 message(单位已不在、网络错误…)必须原文展示,不能压成「删除失败」。
const message = caught instanceof Error ? caught.message : '彻底删除失败'
@@ -192,6 +265,37 @@ async function confirmPurge(): Promise<void> {
purgePending.value = false
}
}
function askRestore(team: Team): void {
restoreTarget.value = team
restoreError.value = null
restoreOpen.value = true
}
async function confirmRestore(): Promise<void> {
const target = restoreTarget.value
if (target === null || restorePending.value) return
restorePending.value = true
restoreError.value = null
try {
const result = await adminApi.client.teams.restore(target.id)
// restored=false 是幂等命中(别人已经恢复过、或它本来就没被隐藏):事实是「它现在是可见的」,
// 提示照实说,不谎报成这次恢复了它。
toast.success(result.restored ? `已恢复「${target.name}」` : `「${target.name}」本就未被彻底删除`)
restoreOpen.value = false
// 两处列表都要刷新:单位回到默认列表,已删除名单里那一行消失。
await data.load(true)
if (showDeleted.value) await loadHiddenTeams()
} catch (caught) {
// 服务端那句原文(单位不存在、编码被占用…)要留在页面上,别压成一句「恢复失败」。
const message = caught instanceof Error ? caught.message : '恢复失败'
restoreError.value = message
toast.error('恢复失败', message)
restoreOpen.value = false
} finally {
restorePending.value = false
}
}
</script>
<template>
@@ -199,6 +303,9 @@ async function confirmPurge(): Promise<void> {
<div class="mx-card-head">
<span class="mx-card-title">参赛单位</span>
<span class="mx-badge mx-badge-neutral">{{ activeCount }} / {{ data.teams.value.length }} 启用</span>
<span v-if="hiddenTeams.length > 0" class="mx-badge mx-badge-error">
另有 {{ hiddenTeams.length }} 个已彻底删除
</span>
</div>
<div class="mx-card-body">
@@ -213,6 +320,11 @@ async function confirmPurge(): Promise<void> {
<option value="active">仅启用</option>
<option value="inactive">仅停用</option>
</select>
<label v-if="isAdmin" class="bd-check bd-show-deleted" title="只有管理员能查看并恢复被彻底删除的单位">
<input v-model="showDeleted" type="checkbox" />
显示已彻底删除的单位
</label>
<span v-if="hiddenLoading" class="mx-spinner" />
</div>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-primary" type="button" :disabled="!data.canWrite.value" @click="openCreate">
@@ -224,6 +336,12 @@ async function confirmPurge(): Promise<void> {
<div v-if="purgeError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ purgeError }}
</div>
<div v-if="hiddenError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> 已彻底删除的单位加载失败:{{ hiddenError }}
</div>
<div v-if="restoreError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ restoreError }}
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
@@ -258,12 +376,24 @@ async function confirmPurge(): Promise<void> {
</td>
<td class="right bd-mono">{{ team.sortOrder }}</td>
<td>
<span class="mx-badge" :class="team.active ? 'mx-badge-success' : 'mx-badge-neutral'">
<span v-if="team.deletedAt != null" class="mx-badge mx-badge-error">已彻底删除</span>
<span v-else class="mx-badge" :class="team.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ team.active ? '启用' : '已停用' }}
</span>
</td>
<td>
<div class="bd-actions">
<!-- 已彻底删除的行只留「恢复」:编辑 / 停用 / 再删一次对不可见的单位都没有意义。 -->
<div v-if="team.deletedAt != null" class="bd-actions">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || restorePending"
@click="askRestore(team)"
>
{{ restorePending && restoreTarget?.id === team.id ? '恢复中…' : '恢复' }}
</button>
</div>
<div v-else class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openEdit(team)">
编辑
</button>
@@ -300,7 +430,7 @@ async function confirmPurge(): Promise<void> {
</table>
</div>
<p class="bd-note">
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,界面上不可恢复,但它的报名、成绩与积分数据全部保留,积分榜不会变化。
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,它的报名、成绩与积分数据全部保留、积分榜不变;管理员勾选「显示已彻底删除的单位」后可以「恢复」它,恢复不改动任何成绩数据。
</p>
</div>
@@ -359,16 +489,28 @@ async function confirmPurge(): Promise<void> {
:open="purgeOpen"
tone="danger"
title="彻底删除该参赛单位?"
:message="`将把「${purgeTarget?.name ?? ''}」从参赛单位列表中移除,之后不再出现在单位列表与报名选择里,界面上无法恢复。`"
:details="[
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
'只是想暂时不让它参加,请用「停用」',
]"
:message="`将把「${purgeTarget?.name ?? ''}」从参赛单位列表中移除,之后不再出现在单位列表与报名选择里。`"
:details="purgeDetails"
confirm-text="彻底删除"
:pending="purgePending"
@confirm="confirmPurge"
@cancel="purgeOpen = false"
/>
<ConfirmDialog
:open="restoreOpen"
tone="info"
title="恢复该参赛单位?"
:message="`「${restoreTarget?.name ?? ''}」将重新出现在单位列表与报名选择里,并恢复为「启用」。`"
:details="[
'彻底删除期间它的报名、成绩与积分一直保留着,所以恢复不会改榜、也不会重算',
'它先前的简称、主题色与排序原样恢复,单位编码不会重复',
]"
confirm-text="恢复该单位"
:pending="restorePending"
@confirm="confirmRestore"
@cancel="restoreOpen = false"
/>
</section>
</template>
@@ -381,6 +523,10 @@ async function confirmPurge(): Promise<void> {
.danger-text {
color: var(--mx-red);
}
.bd-show-deleted {
white-space: nowrap;
font-size: 13px;
}
.purge-error {
display: flex;
align-items: flex-start;
+158 -17
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import type { BackupRestoreResult } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import MockBadge from './MockBadge.vue'
import { backupsApi, describeError, type BackupItem } from './systemApi'
import { backupsApi, describeError, waitForServerOnline, type BackupItem } from './systemApi'
import { adminApi } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -11,8 +14,8 @@ import { useToastStore } from '../../stores/toast'
* 备份(devdocs/08 第 6、8 节)。
*
* 自动备份每 5 分钟一次、发布前再备一次,滚动保留 20 份;
* 但服务端目前只提供「列出 + 立即备份」,没有恢复接口,
* 所以恢复只能按流程手工执行,页面把它写成可照做的步骤而不是假装有按钮。
* 恢复是停机动作:服务端校验备份、留一份恢复前安全副本、换掉库文件,然后优雅退出、
* 由服务管理窗口重新拉起。所以恢复按钮只给管理员,并且必须先过危险确认框。
*/
const auth = useAuthStore()
@@ -24,6 +27,12 @@ const loadError = ref<string | null>(null)
const usingMock = ref(false)
const backingUp = ref(false)
const restoreTarget = ref<BackupItem | null>(null)
const restoring = ref(false)
const restarting = ref(false)
const restoreError = ref<string | null>(null)
const lastRestore = ref<BackupRestoreResult | null>(null)
const isAdmin = computed(() => auth.user?.role === 'admin')
const latest = computed<BackupItem | null>(() => rows.value[0] ?? null)
@@ -67,6 +76,103 @@ function reasonLabel(reason: string): string {
return REASON_LABEL[reason] ?? (reason === '' ? '—' : reason)
}
/**
* 库名与「能不能恢复」都取自服务端给的 kind / dbKind(不再从文件名前缀猜):
* kind 是这份文件的性质(系统库 / 赛事库 / 恢复前安全副本),
* dbKind 是它对应的库——恢复请求要传的就是它。
*/
const DB_KIND_LABEL: Record<string, string> = {
system: '系统库',
event: '赛事库',
}
function kindLabelOf(row: BackupItem): string {
const db = DB_KIND_LABEL[row.dbKind] ?? '未识别'
return row.kind === 'safety' ? `${db}·恢复前副本` : db
}
/** 安全副本的「来源」列显示成「恢复前副本」,普通备份仍显示触发原因。 */
function reasonOf(row: BackupItem): string {
return row.kind === 'safety' ? '恢复前副本' : reasonLabel(row.reason)
}
function canRestore(row: BackupItem): boolean {
return isAdmin.value && !usingMock.value && row.dbKind !== ''
}
/** 要覆盖的那份库,用中文写在确认框里,避免「恢复」两个字被当成无损操作。 */
const restoreScope = computed(() => {
const row = restoreTarget.value
if (row === null) return '当前库'
if (row.dbKind === 'system') return '当前系统库 system.db(账号与会话)'
if (row.dbKind === 'event') return '当前赛事库 event.db(赛程、报名与成绩)'
return '当前库'
})
const restoreMessage = computed(() =>
restoreTarget.value === null ? '' : `将用「${restoreTarget.value.name}」覆盖${restoreScope.value}。`,
)
const restoreDetails = computed(() => {
const row = restoreTarget.value
const lines = [
'覆盖之后,备份时间点之后的改动都会消失(自动备份间隔 5 分钟)。',
'服务端会先留一份恢复前安全副本(每类库保留最近 5 份),回执里会给出文件名,选错时间点还能用它退回。',
'恢复期间服务端会优雅退出并由服务管理窗口重新拉起,页面会自动等待并刷新,请勿关闭服务管理窗口。',
]
// 赛事库备份的文件名里不含项目名,从别的项目备下来的长得一模一样。
if (row !== null && row.dbKind === 'event') {
lines.push('赛事库备份的文件名里不含来源项目,恢复前请确认这份备份确实来自当前项目。')
}
if (row !== null && row.dbKind === 'system') {
lines.push('恢复系统库会连同账号与会话一起换掉,重启后需要用该时间点的账号重新登录。')
}
return lines
})
async function confirmRestore(): Promise<void> {
const row = restoreTarget.value
if (row === null || row.dbKind === '') return
restoring.value = true
restoreError.value = null
try {
// kind 传 dbKind:安全副本也属于某一份库,拿它退回上一个时间点是正当操作。
const result = await adminApi.client.system.restore({ file: row.name, kind: row.dbKind })
lastRestore.value = result
restoreTarget.value = null
if (result.warning !== undefined && result.warning !== '') {
toast.warning('恢复已完成,但有一处异常', result.warning)
}
if (!result.restarting) {
// 服务端没接入优雅重启:文件已经换好,下次重启才生效,不假装在重启。
toast.success('已恢复', result.message)
window.location.reload()
return
}
restarting.value = true
toast.info('正在恢复并重启服务端…', `安全副本:${result.safetyCopy}`)
const online = await waitForServerOnline({
probe: () => adminApi.client.health(),
waitForDown: true,
})
if (online) {
toast.success('服务端已恢复,正在刷新页面')
window.location.reload()
return
}
restarting.value = false
restoreError.value = `等待服务端重启超时(60 秒)。恢复已完成(安全副本:${result.safetyCopy}),请打开服务管理窗口确认服务端状态后手动刷新本页。`
toast.error('服务端重启未在 60 秒内完成', '请在服务管理窗口查看服务端状态')
void load()
} catch (caught) {
// 失败一律发生在动库文件之前(或已自动回滚),所以这里可以直说「当前库没动」。
restoreError.value = describeError(caught)
toast.error('恢复未执行,当前库未被覆盖', restoreError.value)
} finally {
restoring.value = false
}
}
function formatSize(bytes: number): string {
if (bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
@@ -103,6 +209,14 @@ onMounted(() => {
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">只有管理员能查看与创建备份。</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<p v-if="restarting" class="notice">
<span class="mx-activity-dot"></span> 服务端正在重启以应用恢复,请勿关闭服务管理窗口…
</p>
<p v-if="restoreError !== null" class="notice error">{{ restoreError }}</p>
<p v-if="lastRestore !== null" class="notice">
最近一次恢复:<code class="mono">{{ lastRestore.file }}</code> → {{ lastRestore.kindLabel }};
恢复前安全副本 <code class="mono">{{ lastRestore.safetyCopy }}</code>(不参与滚动清理,可再恢复一次退回)。
</p>
<p v-if="latest !== null" class="latest">
最近一次备份:{{ formatDateTime(latest.createdAt) }}
<span class="muted">({{ reasonLabel(latest.reason) }})</span>
@@ -120,21 +234,34 @@ onMounted(() => {
<thead>
<tr>
<th>文件名</th>
<th>来源</th>
<th>库 / 来源</th>
<th class="right">大小</th>
<th>创建时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.name">
<td class="mono name">{{ row.name }}</td>
<td>
<span class="mx-badge" :class="row.reason === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ reasonLabel(row.reason) }}
<span class="mx-badge" :class="row.dbKind === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ kindLabelOf(row) }}
</span>
<span class="mx-badge mx-badge-neutral">{{ reasonOf(row) }}</span>
</td>
<td class="right mono">{{ formatSize(row.size) }}</td>
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canRestore(row) || restoring || restarting"
:title="canRestore(row) ? '用这份备份覆盖当前库' : '管理员且非示例数据模式下才能恢复'"
@click="restoreTarget = row"
>
<MxIcon name="restore" :size="14" /> 恢复
</button>
</td>
</tr>
</tbody>
</table>
@@ -146,32 +273,46 @@ onMounted(() => {
<div class="mx-card-head"><span class="mx-card-title">恢复说明</span></div>
<div class="mx-card-body">
<p class="notice">
备份文件存放在 <code class="mono">data/backups/</code>,滚动保留最近 20 份,不随 git 提交。
服务端暂未提供一键恢复接口,恢复需在停机状态下手工执行:
备份文件存放在 <code class="mono">data/backups/</code>:普通备份(系统库 / 赛事库)滚动保留最近 20 份,
恢复前安全副本每类库单独保留最近 5 份,都不随 git 提交。上表每行的「恢复」会用那份备份覆盖当前库:
</p>
<ol class="steps">
<li>
<strong>停服务</strong>
<span>先停掉后端进程(systemd:<code class="mono">systemctl stop scoreboard</code>),避免恢复过程中继续写入。</span>
<strong>先校验,再动手</strong>
<span>服务端先检查备份能不能打开、关键表在不在;坏文件或选错库直接拒绝,当前库不会被覆盖。</span>
</li>
<li>
<strong>留存现场</strong>
<span>把当前的 <code class="mono">event.db</code> 改名备份,确认恢复失败还能退回。</span>
<strong>先留现场</strong>
<span>动手前给当前库拍一份「恢复前安全副本」(<code class="mono">pre-restore-…db</code>),回执里给出文件名;它不参与 20 份滚动清理,可以再拿来恢复一次退回。</span>
</li>
<li>
<strong>替换数据库</strong>
<span>选一份备份文件复制为 <code class="mono">event.db</code>,同时删除 <code class="mono">event.db-wal</code> 与 <code class="mono">event.db-shm</code>。</span>
<strong>换文件并重启</strong>
<span>断开目标库的连接后整文件替换,服务端随之优雅退出、由服务管理窗口重新拉起;赛事库与系统库互不影响。</span>
</li>
<li>
<strong>重启并核对</strong>
<span>启动服务后检查积分榜与最近一次发布记录,确认数据时间点符合预期。</span>
<span>服务端回来后检查积分榜与最近一次发布记录,确认时间点符合预期;不对就用安全副本再恢复一次。</span>
</li>
</ol>
<p class="notice warn">
恢复会丢弃备份时间点之后的所有改动(自动备份间隔 5 分钟)。现场先改 IP 切换备机、再考虑恢复,
通常比原地恢复更快。
恢复会丢弃备份时间点之后的所有改动(自动备份间隔 5 分钟)。
<strong>赛事库备份的文件名里不含来源项目</strong>,从别的赛事项目备下来的
<code class="mono">event-…db</code> 长得一模一样,恢复前请务必确认来源,别盖错项目。
现场先改 IP 切换备机、再考虑恢复,通常比原地恢复更快。
</p>
</div>
<ConfirmDialog
:open="restoreTarget !== null"
tone="danger"
title="用这份备份覆盖当前数据?"
:message="restoreMessage"
:details="restoreDetails"
confirm-text="恢复并重启"
:pending="restoring"
@confirm="confirmRestore"
@cancel="restoreTarget = null"
/>
</section>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime, resolveAssetUrl } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import {
describeError,
imageAssetsApi,
type OrphanDeleteFailure,
type OrphanImage,
} from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
/**
* 图片资源(孤儿清理)。
*
* 换图只改配置指向,旧文件会一直留在服务端的 `uploads/` 下,久了就是一堆谁也认不出的
* 随机名文件。这里列出「没有被任何配置引用」的图片,勾选后删除。
*
* 判定与删除都在服务端:引用点(赛事设置的三处 + 各单位图标)在删除前会再查一遍,
* 期间刚被配上去的图会被拒删并给中文原因——前端这份清单只负责展示,不是权限依据。
* 删的是文件,不可撤销,所以单张与批量都要二次确认。
*/
const auth = useAuthStore()
const toast = useToastStore()
const rows = ref<OrphanImage[]>([])
const totalBytes = ref(0)
const selected = ref<string[]>([])
const loading = ref(false)
const loadError = ref<string | null>(null)
const busy = ref(false)
const failures = ref<OrphanDeleteFailure[]>([])
/** 待确认的删除:batch 为 false 时是行内单张删除。 */
const pending = ref<{ names: string[]; batch: boolean } | null>(null)
const isAdmin = computed(() => auth.user?.role === 'admin')
const allSelected = computed(() => rows.value.length > 0 && selected.value.length === rows.value.length)
async function load(): Promise<void> {
loading.value = true
loadError.value = null
try {
const list = await imageAssetsApi.listOrphans()
rows.value = list.items
totalBytes.value = list.totalBytes
// 清单变了,之前勾中的文件可能已经不在列表里,勾选一并清空。
selected.value = []
} catch (caught) {
loadError.value = describeError(caught)
} finally {
loading.value = false
}
}
function toggleAll(): void {
selected.value = allSelected.value ? [] : rows.value.map((row) => row.name)
}
function toggle(name: string): void {
selected.value = selected.value.includes(name)
? selected.value.filter((item) => item !== name)
: [...selected.value, name]
}
function askDelete(names: string[], batch: boolean): void {
if (names.length === 0) return
pending.value = { names, batch }
}
async function confirmDelete(): Promise<void> {
const task = pending.value
if (task === null) return
busy.value = true
failures.value = []
try {
if (task.batch) {
const result = await imageAssetsApi.removeOrphans(task.names)
failures.value = result.failed
if (result.deleted.length > 0) toast.success('已删除孤儿图片', `共 ${result.deleted.length} 张`)
if (result.failed.length > 0) toast.error('部分图片未删除', `${result.failed.length} 张被跳过,原因见下方`)
} else {
const name = task.names[0] ?? ''
await imageAssetsApi.remove(name)
toast.success('图片已删除', name)
}
pending.value = null
} catch (caught) {
// 单张删除最可能撞上 409(刚被配置引用),服务端的中文原因直接给操作员看。
toast.error('图片删除失败', describeError(caught))
pending.value = null
} finally {
busy.value = false
await load()
}
}
/** 确认框里最多列 10 个文件名,剩下的只报数量,免得弹窗被长清单撑爆。 */
const confirmDetails = computed(() => {
const task = pending.value
if (task === null) return []
const head = task.names.slice(0, 10)
const rest = task.names.length - head.length
return rest > 0 ? [...head, `…以及其余 ${rest} 个文件`] : head
})
function formatSize(bytes: number): string {
if (bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
onMounted(() => {
void load()
})
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">孤儿图片</span>
<div class="head-actions">
<span v-if="!loading" class="summary">
{{ rows.length }} 张 · {{ formatSize(totalBytes) }}
</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="14" /> 刷新
</button>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!isAdmin || busy || selected.length === 0"
@click="askDelete(selected, true)"
>
<MxIcon name="close" :size="14" /> 删除选中({{ selected.length }})
</button>
</div>
</div>
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">只有管理员能查看与清理上传图片。</p>
<p class="notice">
这里只列「没有被任何配置引用」的图片:赛事会标、大屏会标、大屏背景图,
以及任何一个参赛单位的单位图标都算引用(被彻底删除的单位也算,它可以恢复)。
删除不可撤销;若某张图在列出来之后刚被配到别处,服务端会拒删并说明被谁引用。
</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<div v-if="failures.length > 0" class="notice error">
<div>以下 {{ failures.length }} 张未删除:</div>
<ul class="failure-list">
<li v-for="item in failures" :key="item.name">
<code class="mono">{{ item.name }}</code> —— {{ item.reason }}
</li>
</ul>
</div>
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在扫描 uploads 目录…</div>
<div v-else-if="rows.length === 0" class="mx-empty">
<div class="mx-empty-title">没有孤儿图片</div>
<div class="mx-empty-desc">uploads 目录里的每一张图片都还被配置引用着,没有可以清理的文件</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="check-col">
<input
type="checkbox"
:checked="allSelected"
:disabled="!isAdmin"
aria-label="全选"
@change="toggleAll"
/>
</th>
<th>预览</th>
<th>文件名</th>
<th class="right">大小</th>
<th>修改时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.name">
<td class="check-col">
<input
type="checkbox"
:checked="selected.includes(row.name)"
:disabled="!isAdmin"
:aria-label="`选择 ${row.name}`"
@change="toggle(row.name)"
/>
</td>
<td>
<img class="thumb" :src="resolveAssetUrl(row.url)" alt="" loading="lazy" />
</td>
<td class="mono name">{{ row.name }}</td>
<td class="right mono">{{ formatSize(row.size) }}</td>
<td class="mono muted">{{ formatDateTime(row.modifiedAt) }}</td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!isAdmin || busy"
@click="askDelete([row.name], false)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ConfirmDialog
:open="pending !== null"
tone="danger"
:title="pending?.batch === true ? '删除选中的孤儿图片?' : '删除这张图片?'"
:message="
pending?.batch === true
? `将从服务端的 uploads 目录删除 ${pending?.names.length ?? 0} 个文件,删除后无法恢复。`
: '将从服务端的 uploads 目录删除该文件,删除后无法恢复。若它已被配置引用,服务端会拒绝并说明原因。'
"
:details="confirmDetails"
confirm-text="删除"
:pending="busy"
@confirm="confirmDelete"
@cancel="pending = null"
/>
</section>
</template>
<style scoped>
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.summary {
font-size: 12px;
color: var(--mx-t3);
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.check-col {
width: 34px;
text-align: center;
}
.right {
text-align: right;
}
.name {
font-size: 12px;
word-break: break-all;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.thumb {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: var(--mx-r-xs);
border: 1px solid var(--mx-border);
background: var(--mx-s3);
}
.failure-list {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.state-block {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
padding: 18px 0;
}
</style>
+117 -3
View File
@@ -451,12 +451,29 @@ export interface BackupItem {
size: number
reason: string
createdAt: string
/**
* 备份的性质,由服务端给出(不再让前端从文件名前缀猜):
* system / event / safety(恢复前安全副本);老版本认不出的名字是空串。
*/
kind: BackupKind
/**
* 这份备份对应的库:system / event。安全副本的 kind 是 safety,
* 但恢复请求要传的是 dbKind——它决定覆盖哪一份库。
*/
dbKind: RestoreTargetKind
}
/** 备份文件的性质。 */
export type BackupKind = 'system' | 'event' | 'safety' | ''
/** 恢复请求要覆盖的那份库。 */
export type RestoreTargetKind = 'system' | 'event' | ''
/**
* 两种来源要兼容:
* - GET /api/export/backups 返回 export.BackupInfo(name / size / reason 齐全);
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补。
* 三种来源要兼容:
* - GET /api/export/backups 返回 export.BackupInfo(name / kind / dbKind 齐全);
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补;
* - 示例数据自带 kind / dbKind。
*/
function toBackupItem(value: unknown): BackupItem | null {
if (!isRecord(value)) return null
@@ -470,9 +487,17 @@ function toBackupItem(value: unknown): BackupItem | null {
size: readNumber(value, 'size') ?? 0,
reason: readString(value, 'reason') ?? '',
createdAt,
kind: readBackupKind(value, 'kind'),
dbKind: readBackupKind(value, 'dbKind') as RestoreTargetKind,
}
}
function readBackupKind(source: Record<string, unknown>, key: string): BackupKind {
const value = readString(source, key)
if (value === 'system' || value === 'event' || value === 'safety') return value
return ''
}
function mockBackupList(): BackupItem[] {
const now = Date.now()
return [0, 1, 2].map((offset) => ({
@@ -481,6 +506,8 @@ function mockBackupList(): BackupItem[] {
size: 1_482_752 - offset * 2048,
reason: offset === 0 ? 'manual' : 'auto',
createdAt: new Date(now - offset * 5 * 60_000).toISOString(),
kind: 'event',
dbKind: 'event',
}))
}
@@ -1050,3 +1077,90 @@ export const fontsApi = {
await requestJson<unknown>(`/fonts/${encodeURIComponent(name)}`, { method: 'DELETE' })
},
}
// ---------------------------------------------------------------------------
// 上传图片的删除与孤儿清理
// ---------------------------------------------------------------------------
/** uploads/ 下没有被任何配置引用的图片(GET /api/uploads/orphans)。 */
export interface OrphanImage {
name: string
url: string
size: number
/** RFC3339(服务端按 UTC 下发)。 */
modifiedAt: string
}
export interface OrphanImageList {
items: OrphanImage[]
total: number
totalBytes: number
}
export interface OrphanDeleteFailure {
name: string
reason: string
}
export interface OrphanDeleteResult {
deleted: string[]
failed: OrphanDeleteFailure[]
}
function toOrphanImage(value: unknown): OrphanImage | null {
if (!isRecord(value)) return null
const name = readString(value, 'name')
if (name === null) return null
return {
name,
url: readString(value, 'url') ?? `/uploads/${name}`,
size: readNumber(value, 'size') ?? 0,
modifiedAt: readString(value, 'modifiedAt') ?? '',
}
}
function toOrphanDeleteResult(value: unknown): OrphanDeleteResult {
if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '删除结果响应格式不符', 0, value)
const failed = Array.isArray(value.failed) ? value.failed : []
return {
deleted: readStringArray(value.deleted),
failed: failed.filter(isRecord).map((item) => ({
name: readString(item, 'name') ?? '(未知文件)',
reason: readString(item, 'reason') ?? '删除失败',
})),
}
}
/**
* 上传图片的删除与孤儿清理(admin)。
*
* 一律不降级示例数据:假的孤儿清单会让管理员以为没有垃圾文件要清,
* 而示例模式下的「删除成功」更糟——他会以为文件真的没了。
*/
export const imageAssetsApi = {
async listOrphans(): Promise<OrphanImageList> {
const body = await requestJson<unknown>('/uploads/orphans')
if (!isRecord(body)) throw new ApiError('PARSE_FAILED', '孤儿图片清单响应格式不符', 0, body)
const raw = Array.isArray(body.items) ? body.items : []
const items = raw.map(toOrphanImage).filter((item): item is OrphanImage => item !== null)
return {
items,
total: readNumber(body, 'total') ?? items.length,
totalBytes: readNumber(body, 'totalBytes') ?? 0,
}
},
/** 删除单张图片;仍被引用时服务端回 409,错误文案原样抛出。 */
async remove(name: string): Promise<void> {
await requestJson<unknown>(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' })
},
/** 批量删除明确列出的孤儿;服务端逐条回报,被引用的条目不会删。 */
async removeOrphans(names: string[]): Promise<OrphanDeleteResult> {
const body = await requestJson<unknown>('/uploads/orphans/delete', {
method: 'POST',
json: { names },
})
return toOrphanDeleteResult(body)
},
}
+40 -20
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime, type AuditLog } from '@/shared'
import { formatDateTime, type AuditLog, type AuditLogQuery } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import MockBadge from '../components/system/MockBadge.vue'
@@ -14,8 +14,8 @@ import { useToastStore } from '../stores/toast'
* 审计日志(devdocs/08 第 3 节)。
*
* 审计日志只读、不可删;所有写操作都记录操作人、时间、IP、request_id 与 before/after。
* 服务端的 /api/audit-logs 只支持 entityType / userId 过滤,没有 action 参数,
* 所以这里一次取最近 200 条再在前端按动作与实体筛选——宁可把窗口写清楚,也不假装能全库筛。
* 服务端的 /api/audit-logs 支持 action(子串模糊)/ entityType / userId 过滤,三者可组合;
* 动作过滤走服务端(全库范围),实体过滤与分页作用于返回的最近 200 条窗口。
*/
const PAGE_SIZE = 20
@@ -36,23 +36,21 @@ const actionFilter = ref('')
const entityFilter = ref('')
const expandedId = ref<number | null>(null)
const actionOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.action))
return [...set].sort((a, b) => a.localeCompare(b))
})
const entityOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.entityType))
return [...set].sort((a, b) => a.localeCompare(b))
})
const filtered = computed(() =>
rows.value.filter(
// 动作过滤在服务端已按子串模糊执行;这里再按同一口径过滤一遍,
// 一是 mock 模式下服务端不存在,二是避免窗口内出现与关键字不符的记录。
const filtered = computed(() => {
const keyword = actionFilter.value.trim().toLowerCase()
return rows.value.filter(
(row) =>
(actionFilter.value === '' || row.action === actionFilter.value) &&
(keyword === '' || row.action.toLowerCase().includes(keyword)) &&
(entityFilter.value === '' || row.entityType === entityFilter.value),
),
)
)
})
const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)))
@@ -74,6 +72,8 @@ function resetFilters(): void {
actionFilter.value = ''
entityFilter.value = ''
page.value = 1
// 动作过滤在服务端执行,清空后要重新取窗口才能恢复全量数据。
void load()
}
function onFilterChange(): void {
@@ -81,6 +81,12 @@ function onFilterChange(): void {
page.value = 1
}
function applyActionFilter(): void {
// 动作过滤要等服务端返回结果,先回第一页再重新拉窗口。
page.value = 1
void load()
}
const isAdmin = computed(() => auth.user?.role === 'admin')
async function load(): Promise<void> {
@@ -90,7 +96,13 @@ async function load(): Promise<void> {
loadError.value = null
try {
// 用局部 mock 标记判断本批数据是不是示例,不读会被并发请求清掉的全局状态。
const result = await adminApi.auditLogsWithMock({ page: 1, size: WINDOW_SIZE })
// 动作过滤交给服务端(全库范围),窗口只决定实体筛选与分页的基数。
const query: AuditLogQuery = {
page: 1,
size: WINDOW_SIZE,
action: actionFilter.value.trim() || undefined,
}
const result = await adminApi.auditLogsWithMock(query)
rows.value = result.data.items
usingMock.value = result.mock
if (page.value > pageCount.value) page.value = 1
@@ -129,10 +141,14 @@ onMounted(() => {
<div class="filter">
<label class="mx-label" for="log-action">动作</label>
<div class="mx-input-wrap">
<select id="log-action" v-model="actionFilter" class="mx-input" @change="onFilterChange">
<option value="">全部动作</option>
<option v-for="action in actionOptions" :key="action" :value="action">{{ action }}</option>
</select>
<input
id="log-action"
v-model="actionFilter"
class="mx-input"
type="search"
placeholder="如 team. / result.publish"
@keyup.enter="applyActionFilter"
/>
</div>
</div>
<div class="filter">
@@ -144,12 +160,16 @@ onMounted(() => {
</select>
</div>
</div>
<button class="mx-btn mx-btn-secondary clear" type="button" :disabled="loading" @click="applyActionFilter">
查询
</button>
<button class="mx-btn mx-btn-ghost clear" type="button" @click="resetFilters">清除筛选</button>
</div>
<p class="hint">
服务端 <code class="mono">/api/audit-logs</code> 只支持按实体与操作人过滤,暂不支持 action 参数,
因此筛选与分页作用于最近 {{ WINDOW_SIZE }} 条记录。
动作由服务端 <code class="mono">/api/audit-logs?action=</code> 按子串模糊过滤(全库范围,
如 <code class="mono">team.</code> 能命中 team.create / team.remove);
实体筛选与分页作用于返回的最近 {{ WINDOW_SIZE }} 条记录。
</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
+5 -1
View File
@@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader.vue'
import UsersPanel from '../components/system/UsersPanel.vue'
import BackupPanel from '../components/system/BackupPanel.vue'
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
import ImageAssetsPanel from '../components/system/ImageAssetsPanel.vue'
import SessionPanel from '../components/system/SessionPanel.vue'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -15,13 +16,15 @@ import { useEventStore } from '../stores/event'
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
*/
type TabKey = 'users' | 'session' | 'backup' | 'event'
type TabKey = 'users' | 'session' | 'backup' | 'images' | 'event'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'users', label: '用户管理' },
// 登录有效期是机器级配置(system.db),与赛事信息那种随赛事包走的配置分开摆。
{ key: 'session', label: '登录有效期' },
{ key: 'backup', label: '备份' },
// 上传图片的孤儿清理:删文件不可撤销,只有管理员能看,所以挂在系统页而不是赛事信息页。
{ key: 'images', label: '图片资源' },
{ key: 'event', label: '赛事信息' },
]
@@ -75,6 +78,7 @@ watch(
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
<SessionPanel v-else-if="isAdmin && activeTab === 'session'" />
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
<ImageAssetsPanel v-else-if="isAdmin && activeTab === 'images'" />
<EventInfoPanel v-else />
</div>
</template>
@@ -0,0 +1,116 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { imageAssetsApi } from '../components/system/systemApi'
/**
* 图片删除 / 孤儿清理接口的前端契约:路径、方法、请求体与响应形状。
*
* 这一组端点的红线是「只删点名的文件」,路径或字段名写错时现场看到的是
* 「点了删除但列表没变」这类静默故障,所以这里用 stub fetch 钉住真正发出去的请求。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function stubFetch(reply: (captured: Captured) => Response): Captured {
const captured: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
captured.url = String(input)
captured.method = init?.method ?? 'GET'
captured.body = init?.body
return reply(captured)
}) as typeof fetch
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
return captured
}
const ORPHAN = {
name: '3f6a1c.png',
url: '/uploads/3f6a1c.png',
size: 2048,
modifiedAt: '2026-09-11T03:04:05Z',
}
test('imageAssetsApi.listOrphans 读 GET /api/uploads/orphans 并带出总数与占用', async () => {
const captured = stubFetch(() =>
jsonResponse({ ok: true, data: { items: [ORPHAN], total: 1, totalBytes: 2048 } }),
)
const list = await imageAssetsApi.listOrphans()
assert.equal(captured.url, '/api/uploads/orphans')
assert.equal(captured.method, 'GET')
assert.deepEqual(list, { items: [ORPHAN], total: 1, totalBytes: 2048 })
})
// 形状不对时必须报解析失败,而不是把 undefined 当成「没有孤儿」骗管理员说目录干净。
test('imageAssetsApi.listOrphans 对非法响应报解析失败', async () => {
stubFetch(() => jsonResponse({ ok: true, data: [ORPHAN] }))
await assert.rejects(() => imageAssetsApi.listOrphans(), (error: Error) => error.message.includes('孤儿图片'))
})
test('imageAssetsApi.remove 以 DELETE 删单张并把文件名转义进路径', async () => {
const captured = stubFetch(() => jsonResponse({ ok: true, data: null }))
await imageAssetsApi.remove('3f6a 1c.png')
assert.equal(captured.url, `/api/uploads/${encodeURIComponent('3f6a 1c.png')}`)
assert.equal(captured.method, 'DELETE')
})
// 被引用的图片服务端回 409:原因必须原样透出,否则管理员不知道该去哪改配置。
test('imageAssetsApi.remove 原样抛出「正在被使用」的 409 文案', async () => {
stubFetch(() =>
jsonResponse(
{
ok: false,
error: {
code: 'CONFLICT',
message: '图片 3f6a1c.png 正在被使用(赛事信息·大屏背景图),请先在对应位置改掉或清除后再删除',
},
},
409,
),
)
await assert.rejects(
() => imageAssetsApi.remove('3f6a1c.png'),
(error: Error) => error.message.includes('正在被使用'),
)
})
test('imageAssetsApi.removeOrphans POST 明确列出的文件名并解析逐条回报', async () => {
const captured = stubFetch(() =>
jsonResponse({
ok: true,
data: { deleted: ['3f6a1c.png'], failed: [{ name: 'aabb.png', reason: '仍被引用(单位「第一中学」的单位图标),已跳过' }] },
}),
)
const result = await imageAssetsApi.removeOrphans(['3f6a1c.png', 'aabb.png'])
assert.equal(captured.url, '/api/uploads/orphans/delete')
assert.equal(captured.method, 'POST')
// 只断言 names:send() 会按幂等约定往请求体里补一个 requestId,整包深比较会被它带偏。
assert.deepEqual((JSON.parse(String(captured.body)) as { names: string[] }).names, [
'3f6a1c.png',
'aabb.png',
])
assert.deepEqual(result.deleted, ['3f6a1c.png'])
assert.equal(result.failed.length, 1)
assert.match(result.failed[0]?.reason ?? '', /仍被引用/)
})
@@ -0,0 +1,152 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 参赛单位「恢复」的前端契约:方法 / 路径、已删除单位的列表开关,以及失败时服务端那句话的传递。
*
* 恢复是「彻底删除」(假删除)的反操作:报名、成绩与积分一行没动,所以服务端只回报
* restored 与恢复后的单位,不重算、不推榜单。这里把契约钉住,免得有人按别的写法改坏。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例,
// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
test('恢复走 POST /api/teams/{id}/restore 并回报 restored', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
team: { id: 3, code: 'A', name: '第一分公司', shortName: '一司', logoPath: null, color: null, sortOrder: 0, active: true, deletedAt: null },
restored: true,
},
}),
)
const result = await adminApi.client.teams.restore(3)
assert.equal(lastRequest.url, '/api/teams/3/restore')
assert.equal(lastRequest.method, 'POST')
assert.equal(result.restored, true)
assert.equal(result.team.deletedAt, null)
assert.equal(result.team.active, true)
})
// 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,点「恢复」恢复不了还看不出原因。
test('恢复带上 CSRF 双提交 Cookie 的请求头', async () => {
respondWith(() => jsonResponse({ ok: true, data: { team: {}, restored: false } }))
const globalWithDocument = globalThis as { document?: unknown }
globalWithDocument.document = { cookie: 'csrf_token=token-123' }
try {
await adminApi.client.teams.restore(3)
} finally {
delete globalWithDocument.document
}
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123')
})
// 幂等命中:服务端回 restored=false,前端不该把它当成失败。
test('单位本就未删除时 restored=false 也算成功', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
team: { id: 4, code: 'B', name: '第二分公司', shortName: null, logoPath: null, color: null, sortOrder: 1, active: false, deletedAt: null },
restored: false,
},
}),
)
const result = await adminApi.client.teams.restore(4)
assert.equal(result.restored, false)
// 停用的单位不能被「恢复」顺手改成启用。
assert.equal(result.team.active, false)
})
// 列已彻底删除的单位要显式带开关,否则服务端不会把隐藏的行放进来。
test('includeDeleted 拼成 ?includeDeleted=1', async () => {
respondWith(() => jsonResponse({ ok: true, data: [] }))
await adminApi.client.teams.list({ includeDeleted: true })
assert.equal(lastRequest.url, '/api/teams?includeDeleted=1')
assert.equal(lastRequest.method, 'GET')
})
// 默认列表不能带上这个开关:非 admin 带它会被服务端 403,整个单位列表就空了。
test('默认列表不带 includeDeleted 查询串', async () => {
respondWith(() => jsonResponse({ ok: true, data: [] }))
await adminApi.client.teams.list()
assert.equal(lastRequest.url, '/api/teams')
})
// 失败时不吞错误:服务端那句原文(单位不存在 / 编码被占用)必须原样带出。
test('恢复失败时服务端那句原文原样带出', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '单位不存在' } }, 404),
)
await assert.rejects(
() => adminApi.client.teams.restore(999999),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '单位不存在')
return true
},
)
})
test('恢复撞上编码冲突时把 409 的原文带出', async () => {
respondWith(() =>
jsonResponse(
{ ok: false, error: { code: 'CONFLICT', message: '单位编码 A 已被另一个可见单位占用,请先处理那个单位再恢复' } },
409,
),
)
await assert.rejects(
() => adminApi.client.teams.restore(3),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 409)
assert.equal(error.code, 'CONFLICT')
assert.match(error.message, /已被另一个可见单位占用/)
return true
},
)
})
+562
View File
@@ -0,0 +1,562 @@
/**
* 自定义大屏模板的插值引擎(设计稿 devdocs/16 §3):解析 → 渲染 → 收集警告。
*
* 纯函数:不碰 DOM、不执行模板里的任何字节(模板是数据不是代码,这里没有 eval / new Function),
* 所以 `node --test` 能直接跑,M4 的桥只负责把 html 结果写进 iframe。
*
* 失败口径按设计稿 §3.1 / §9:取不到值 → 空串 + 一条警告,不打断渲染;
* 块结构错误 → 返回结构化 error(带行列号),**不抛异常**,由调用方决定要不要降级。
*/
/** 一条警告的成因。渲染期只产这些码,解析期不产警告。 */
export type TemplateWarningCode =
/** 路径取不到值(含中间段缺失、对数字取属性) */
| 'missing'
/** 路径末段取到 null */
| 'null'
/** 不是设计稿支持的路径写法(运算符、过滤器、多余的 /、@ 关键字拼错…) */
| 'invalid-path'
/** ../ 超过一层,或已到最外层再往上 */
| 'parent-depth'
/** {{#each}} 的数据源不是数组 */
| 'not-array'
/** 插值目标是对象/数组/函数,渲染不出文本 */
| 'not-scalar'
export interface TemplateWarning {
code: TemplateWarningCode
/** 触发警告的原始路径文本(不含花括号) */
path: string
/** 中文可读描述,供预览直接显示 */
message: string
/** 1 起 */
line: number
/** 1 起 */
column: number
/** 同一 (code, path) 在本次渲染里出现的次数,最小 1:循环里同一处笔误只报一条 */
count: number
}
/** 解析期的结构错误(设计稿 §3.1 的「硬失败」,但不抛异常,见文件头)。 */
export type TemplateErrorCode =
| 'unclosed-tag'
| 'unclosed-block'
| 'unexpected-close'
| 'block-mismatch'
| 'unexpected-else'
| 'duplicate-else'
| 'unknown-block'
| 'empty-block-path'
export interface TemplateError {
code: TemplateErrorCode
message: string
/** 1 起 */
line: number
/** 1 起,字符(UTF-16 码元)计 */
column: number
/** 0 起的源码偏移,便于预览精确跳转 */
offset: number
}
export interface TextNode {
type: 'text'
value: string
}
export interface ValueNode {
type: 'value'
path: PathRef
/** true = {{{ }}} / {{& }} 原样输出 */
raw: boolean
line: number
column: number
}
export interface SectionNode {
type: 'each' | 'if'
path: PathRef
body: TemplateNode[]
/** {{else}} 分支;没有 else 时为 null */
alternative: TemplateNode[] | null
line: number
column: number
offset: number
}
export type TemplateNode = TextNode | ValueNode | SectionNode
/** 解析后的路径引用:parents 是 ../ 层数,segments 是点号分段。 */
export interface PathRef {
raw: string
parents: number
segments: string[]
/** false = 不是支持的写法,渲染时按空串处理并给 invalid-path 警告 */
valid: boolean
}
export interface ParseResult {
/** error 非 null 时恒为空数组:半截语法树没有可用信息,不如只给错误位置 */
ast: TemplateNode[]
error: TemplateError | null
}
export interface RenderResult {
html: string
warnings: TemplateWarning[]
/** 结构错误;非 null 时 html 恒为空串 */
error: TemplateError | null
}
/** 只有数组下标与 length 能在数组上取,其它键一律算取不到(设计稿 §3.5 的空串口径)。 */
const ARRAY_INDEX_RE = /^[0-9]+$/
/** 路径分段允许的字符:数据键都是 ASCII,表达式(空格、+、|)会在这里被判为非法。 */
const SEGMENT_RE = /^[A-Za-z0-9_$@]+$/
const LOOP_KEYS = new Set(['@index', '@first', '@last'])
const BLOCK_KEYWORDS = new Set(['each', 'if'])
const ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
function escapeHtml(input: string): string {
return input.replace(/[&<>"']/g, (ch) => ESCAPES[ch] ?? ch)
}
/**
* 设计稿 §3.3 写死的真值表,不要在这里加 JS 的隐式转换:
* ''、0、NaN、[]、{} 一律为假;' '、'0' 这类非空串为真。
*/
function isTruthy(value: unknown): boolean {
if (value === undefined || value === null || value === false) return false
if (typeof value === 'number') return value !== 0 && !Number.isNaN(value)
if (typeof value === 'string') return value !== ''
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value as object).length > 0
return true
}
/**
* 解析点号路径。非法路径不抛错(设计稿 §3.1:未知 token → 空串 + 警告),
* 但要在这里先判死,渲染时才不用为每个分段重复兜底。
*/
function parsePath(raw: string): PathRef {
let rest = raw
let parents = 0
while (rest.startsWith('../')) {
parents += 1
rest = rest.slice(3)
}
// 设计稿 §3.1:../ 最多一层,多层按空串处理;这里把带 / 的剩余写法一并判非法
if (rest.includes('/')) return { raw, parents, segments: [], valid: false }
const segments = rest === '' ? [] : rest.split('.')
if (segments.length === 0) return { raw, parents, segments, valid: false }
for (const segment of segments) {
if (!SEGMENT_RE.test(segment)) return { raw, parents, segments, valid: false }
if (segment.startsWith('@') && !LOOP_KEYS.has(segment)) {
return { raw, parents, segments, valid: false }
}
}
return { raw, parents, segments, valid: true }
}
interface TextToken {
kind: 'text'
value: string
start: number
end: number
}
interface TagToken {
kind: 'value' | 'open' | 'close' | 'else'
/** open/close 上的块名 */
block: 'each' | 'if' | null
rawPath: string
raw: boolean
start: number
end: number
line: number
column: number
}
type Token = TextToken | TagToken
interface Frame {
value: unknown
/** 非循环帧(根)为 -1 */
index: number
total: number
}
const WARNING_TEXT: Record<TemplateWarningCode, (path: string) => string> = {
missing: (p) => `路径「${p}」取不到值,已按空串渲染`,
null: (p) => `路径「${p}」的值为 null,已按空串渲染`,
'invalid-path': (p) => `「${p}」不是支持的写法(只支持点号路径、this、@index/@first/@last 与一层 ../),已按空串处理`,
'parent-depth': (p) => `「${p}」的 ../ 超过一层或已到最外层,已按空串处理`,
'not-array': (p) => `「${p}」不是数组,{{#each}} 未输出任何内容`,
'not-scalar': (p) => `「${p}」是对象/数组,插值只能渲染文本,已按空串处理`,
}
const ERROR_TEXT: Record<TemplateErrorCode, string> = {
'unclosed-tag': '标签没有闭合的 }}',
'unclosed-block': '块没有闭合的结束标记',
'unexpected-close': '多出来的结束标记',
'block-mismatch': '结束标记与最近的块不配对',
'unexpected-else': '{{else}} 只能出现在 {{#if}} 块内',
'duplicate-else': '同一个 {{#if}} 块里出现了多个 {{else}}',
'unknown-block': '不支持的块标记',
'empty-block-path': '块标记缺少路径',
}
/** 同一 (code, path) 只留第一条,重复出现只累加 count:循环里的笔误不该刷满警告清单。 */
class WarningCollector {
private readonly seen = new Map<string, TemplateWarning>()
private readonly list: TemplateWarning[] = []
warn(code: TemplateWarningCode, path: string, line: number, column: number): void {
const key = `${code}|${path}`
const hit = this.seen.get(key)
if (hit) {
hit.count += 1
return
}
const warning: TemplateWarning = {
code,
path,
message: WARNING_TEXT[code](path),
line,
column,
count: 1,
}
this.seen.set(key, warning)
this.list.push(warning)
}
get warnings(): TemplateWarning[] {
return this.list
}
}
function buildLineStarts(source: string): number[] {
const starts = [0]
for (let i = 0; i < source.length; i += 1) {
if (source.charCodeAt(i) === 10) starts.push(i + 1)
}
return starts
}
function positionAt(starts: number[], offset: number): { line: number; column: number } {
let lo = 0
let hi = starts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (starts[mid] <= offset) lo = mid
else hi = mid - 1
}
return { line: lo + 1, column: offset - starts[lo] + 1 }
}
function makeError(
code: TemplateErrorCode,
starts: number[],
offset: number,
detail = '',
): TemplateError {
const { line, column } = positionAt(starts, offset)
return {
code,
message: detail === '' ? ERROR_TEXT[code] : `${ERROR_TEXT[code]}:${detail}`,
line,
column,
offset,
}
}
function tokenize(
source: string,
starts: number[],
): { tokens: Token[]; error: TemplateError | null } {
const tokens: Token[] = []
let cursor = 0
let textStart = 0
while (cursor < source.length) {
const open = source.indexOf('{{', cursor)
if (open < 0) break
if (open > textStart) {
tokens.push({ kind: 'text', value: source.slice(textStart, open), start: textStart, end: open })
}
const triple = source.startsWith('{{{', open)
const contentStart = open + (triple ? 3 : 2)
const close = source.indexOf(triple ? '}}}' : '}}', contentStart)
if (close < 0) {
return { tokens, error: makeError('unclosed-tag', starts, open) }
}
const end = close + (triple ? 3 : 2)
const inner = source.slice(contentStart, close).trim()
const { line, column } = positionAt(starts, open)
if (inner.startsWith('#')) {
const rest = inner.slice(1).trim()
const keyword = rest.split(/\s/)[0] ?? ''
if (!BLOCK_KEYWORDS.has(keyword)) {
return { tokens, error: makeError('unknown-block', starts, open, `{{#${rest}}} 不是 {{#each}} 或 {{#if}}`) }
}
// 操作数取「关键字之后的整段」:{{#if rank > 3}} 要整段判为非法路径 → 假(设计稿 §3.3),
// 不能只取第一个词,否则会解析成一个能取到值的 rank 而变成真。
const rawPath = rest.slice(keyword.length).trim()
if (rawPath === '') {
return { tokens, error: makeError('empty-block-path', starts, open, `{{#${keyword}}}`) }
}
tokens.push({ kind: 'open', block: keyword as 'each' | 'if', rawPath, raw: false, start: open, end, line, column })
} else if (inner.startsWith('/')) {
const keyword = inner.slice(1).trim()
if (!BLOCK_KEYWORDS.has(keyword)) {
return { tokens, error: makeError('unknown-block', starts, open, `{{/${keyword}}} 不是 {{/each}} 或 {{/if}}`) }
}
tokens.push({ kind: 'close', block: keyword as 'each' | 'if', rawPath: '', raw: false, start: open, end, line, column })
} else if (inner === 'else') {
tokens.push({ kind: 'else', block: null, rawPath: '', raw: false, start: open, end, line, column })
} else if (!triple && inner.startsWith('&')) {
// {{& x}} 与 {{{ x }}} 同义(设计稿 §3.4)
tokens.push({ kind: 'value', block: null, rawPath: inner.slice(1).trim(), raw: true, start: open, end, line, column })
} else {
tokens.push({ kind: 'value', block: null, rawPath: inner, raw: triple, start: open, end, line, column })
}
cursor = end
textStart = end
}
if (textStart < source.length) {
tokens.push({ kind: 'text', value: source.slice(textStart), start: textStart, end: source.length })
}
return { tokens, error: null }
}
/**
* Mustache 的 standalone line 规则(设计稿 §3.2):一行里只有空白 + 一个块标记时,
* 整行(含缩进与换行)吞掉。只对块标记生效,插值行照旧保留——缩进写模板不该在输出里留空行。
*/
function stripStandaloneLines(source: string, tokens: Token[]): void {
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i]
if (token.kind === 'text' || token.kind === 'value') continue
const lineStart = source.lastIndexOf('\n', token.start - 1) + 1
if (/[^\t ]/.test(source.slice(lineStart, token.start))) continue
const newline = source.indexOf('\n', token.end)
const lineEnd = newline < 0 ? source.length : newline
// \r 归前一行(CRLF 模板),所以行尾只允许空白与 \r
if (/[^\t\r ]/.test(source.slice(token.end, lineEnd))) continue
const prev = tokens[i - 1]
if (prev && prev.kind === 'text') prev.value = prev.value.replace(/[\t ]*$/, '')
const next = tokens[i + 1]
// 只吃掉「标记尾随空白 + 换行」,下一行自己的缩进要留着
if (next && next.kind === 'text') next.value = next.value.replace(/^[\t ]*\r?\n/, '')
}
}
function buildAst(
tokens: Token[],
starts: number[],
): { ast: TemplateNode[]; error: TemplateError | null } {
const root: TemplateNode[] = []
interface Entry {
node: SectionNode | null
list: TemplateNode[]
part: 'body' | 'alternative'
}
const stack: Entry[] = [{ node: null, list: root, part: 'body' }]
const top = (): Entry => stack[stack.length - 1]
for (const token of tokens) {
if (token.kind === 'text') {
if (token.value !== '') top().list.push({ type: 'text', value: token.value })
continue
}
if (token.kind === 'value') {
top().list.push({
type: 'value',
path: parsePath(token.rawPath),
raw: token.raw,
line: token.line,
column: token.column,
})
continue
}
if (token.kind === 'open') {
const node: SectionNode = {
type: token.block as 'each' | 'if',
path: parsePath(token.rawPath),
body: [],
alternative: null,
line: token.line,
column: token.column,
offset: token.start,
}
top().list.push(node)
stack.push({ node, list: node.body, part: 'body' })
continue
}
if (token.kind === 'else') {
const entry = top()
if (!entry.node || entry.node.type !== 'if') {
return { ast: [], error: makeError('unexpected-else', starts, token.start) }
}
if (entry.part === 'alternative') {
return { ast: [], error: makeError('duplicate-else', starts, token.start) }
}
entry.node.alternative = []
entry.list = entry.node.alternative
entry.part = 'alternative'
continue
}
// close
if (stack.length === 1) {
return { ast: [], error: makeError('unexpected-close', starts, token.start, `{{/${token.block}}}`) }
}
const entry = top()
if (!entry.node || entry.node.type !== token.block) {
return {
ast: [],
error: makeError('block-mismatch', starts, token.start, `{{/${token.block}}} 与最近的 {{#${entry.node?.type ?? '?'}}} 不配对`),
}
}
stack.pop()
}
if (stack.length > 1) {
const unclosed = stack[1].node
return { ast: [], error: makeError('unclosed-block', starts, unclosed?.offset ?? 0, `{{#${unclosed?.type ?? '?'}}}`) }
}
return { ast: root, error: null }
}
function resolvePath(ref: PathRef, stack: Frame[]): { value: unknown; code: TemplateWarningCode | null } {
if (!ref.valid) return { value: undefined, code: 'invalid-path' }
if (ref.parents > 1) return { value: undefined, code: 'parent-depth' }
const baseIndex = stack.length - 1 - ref.parents
if (baseIndex < 0) return { value: undefined, code: 'parent-depth' }
const frame = stack[baseIndex]
let segments = ref.segments
if (segments[0]?.startsWith('@')) {
if (segments.length > 1) return { value: undefined, code: 'invalid-path' }
// 循环外没有 @index:按取不到处理(设计稿 §3.1 未定义 → 空串)
if (baseIndex === 0) return { value: undefined, code: 'missing' }
if (segments[0] === '@index') return { value: frame.index, code: null }
if (segments[0] === '@first') return { value: frame.index === 0, code: null }
return { value: frame.index === frame.total - 1, code: null }
}
// 设计稿 §3.2:循环体内 this.xxx 与直接 xxx 等价
if (segments[0] === 'this') segments = segments.slice(1)
let current = frame.value
for (const segment of segments) {
if (current === undefined) return { value: undefined, code: 'missing' }
if (current === null) return { value: undefined, code: 'null' }
if (Array.isArray(current)) {
if (segment === 'length') {
current = current.length
continue
}
if (!ARRAY_INDEX_RE.test(segment)) return { value: undefined, code: 'missing' }
const index = Number(segment)
if (index >= current.length) return { value: undefined, code: 'missing' }
current = current[index]
continue
}
if (typeof current === 'object') {
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
return { value: undefined, code: 'missing' }
}
current = (current as Record<string, unknown>)[segment]
continue
}
// 对数字/布尔/字符串取属性 → 空串(设计稿 §3.5)
return { value: undefined, code: 'missing' }
}
if (current === undefined) return { value: undefined, code: 'missing' }
if (current === null) return { value: undefined, code: 'null' }
return { value: current, code: null }
}
function renderNodes(nodes: TemplateNode[], stack: Frame[], out: string[], sink: WarningCollector): void {
for (const node of nodes) {
if (node.type === 'text') {
out.push(node.value)
continue
}
if (node.type === 'value') {
const resolved = resolvePath(node.path, stack)
if (resolved.code) sink.warn(resolved.code, node.path.raw, node.line, node.column)
const { text, code } = valueToText(resolved.value)
if (code) sink.warn(code, node.path.raw, node.line, node.column)
out.push(node.raw ? text : escapeHtml(text))
continue
}
const resolved = resolvePath(node.path, stack)
if (resolved.code) sink.warn(resolved.code, node.path.raw, node.line, node.column)
if (node.type === 'if') {
if (isTruthy(resolved.value)) renderNodes(node.body, stack, out, sink)
else if (node.alternative) renderNodes(node.alternative, stack, out, sink)
continue
}
// 设计稿 §3.2:只支持数组;null/undefined 前面已按空值告警,非数组另告一条
if (Array.isArray(resolved.value)) {
const total = resolved.value.length
for (let i = 0; i < total; i += 1) {
renderNodes(node.body, stack.concat({ value: resolved.value[i], index: i, total }), out, sink)
}
} else if (resolved.value !== undefined && resolved.value !== null) {
sink.warn('not-array', node.path.raw, node.line, node.column)
}
}
}
/**
* 对象/数组不落成 [object Object]:屏上要的是文本,渲染不出来就给空串 + 警告。
* undefined/null 不在这里告警——取值阶段已经报过 missing/null,重复报会刷屏。
*/
function valueToText(value: unknown): { text: string; code: TemplateWarningCode | null } {
if (value === undefined || value === null) return { text: '', code: null }
if (typeof value === 'string') return { text: value, code: null }
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return { text: String(value), code: null }
}
return { text: '', code: 'not-scalar' }
}
export function parseTemplate(source: string): ParseResult {
const starts = buildLineStarts(source)
const { tokens, error } = tokenize(source, starts)
if (error) return { ast: [], error }
stripStandaloneLines(source, tokens)
return buildAst(tokens, starts)
}
/** 渲染。任何情况下都不抛异常:结构错误走返回值里的 error,取不到值走 html 空串 + warnings。 */
export function renderTemplate(source: string, data: unknown = {}): RenderResult {
const { ast, error } = parseTemplate(source)
if (error) return { html: '', warnings: [], error }
const sink = new WarningCollector()
const out: string[] = []
renderNodes(ast, [{ value: data, index: -1, total: -1 }], out, sink)
return { html: out.join(''), warnings: sink.warnings, error: null }
}
@@ -0,0 +1,141 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 块语法口径:设计稿 §3.2(#each)、§3.3(#if/else 真值表)、§3.2 的 standalone line 吞行规则。
test('#each:空数组不输出循环体,也不产生警告', () => {
const result = renderTemplate('<ul>{{#each rows}}<li>{{ this }}</li>{{/each}}</ul>', { rows: [] })
assert.equal(result.html, '<ul></ul>')
assert.deepEqual(result.warnings, [])
})
test('#each:null/undefined 当空数组,给一条警告', () => {
const missing = renderTemplate('[{{#each rows}}x{{/each}}]', {})
assert.equal(missing.html, '[]')
assert.equal(missing.warnings.length, 1)
assert.equal(missing.warnings[0].code, 'missing')
const nullCase = renderTemplate('[{{#each rows}}x{{/each}}]', { rows: null })
assert.equal(nullCase.html, '[]')
assert.equal(nullCase.warnings[0].code, 'null')
})
test('#each:非数组只告警不渲染(设计稿 §3.2 只支持数组)', () => {
const result = renderTemplate('[{{#each rows}}x{{/each}}]', { rows: 'abc' })
assert.equal(result.html, '[]')
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].code, 'not-array')
})
test('#each:多条 + @index/@first/@last', () => {
const source = '{{#each rows}}{{ @index }}:{{ this }}:{{ @first }}:{{ @last }};{{/each}}'
const result = renderTemplate(source, { rows: ['a', 'b'] })
assert.equal(result.html, '0:a:true:false;1:b:false:true;')
assert.deepEqual(result.warnings, [])
})
test('#each:this.xxx 与直接 xxx 等价(设计稿 §3.2)', () => {
const data = { rows: [{ n: 1 }, { n: 2 }] }
assert.equal(renderTemplate('{{#each rows}}{{ this.n }}/{{ n }},{{/each}}', data).html, '1/1,2/2,')
})
test('#each:嵌套循环里 this 逐层生效、../ 弹一层', () => {
const data = { groups: [{ name: 'A', items: ['x', 'y'] }, { name: 'B', items: ['z'] }] }
const source = '{{#each groups}}{{#each this.items}}{{ ../this.name }}:{{ this }};{{/each}}{{/each}}'
const result = renderTemplate(source, data)
assert.equal(result.html, 'A:x;A:y;B:z;')
assert.deepEqual(result.warnings, [])
})
test('未定义路径作 #each 数据源:嵌套时仍按空数组处理', () => {
const result = renderTemplate('{{#each groups}}{{#each this.items}}{{ this }}{{/each}}{{#each this.absent}}x{{/each}}{{/each}}', {
groups: [{ items: ['a'] }],
})
assert.equal(result.html, 'a')
assert.equal(result.warnings[0].code, 'missing')
assert.equal(result.warnings[0].path, 'this.absent')
})
test('../ 超过一层:空串 + parent-depth 警告(设计稿 §3.1)', () => {
const result = renderTemplate('{{#each rows}}{{ ../../a }}{{/each}}', { a: 1, rows: [1] })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'parent-depth')
})
test('../ 在根作用域:空串 + parent-depth 警告', () => {
const result = renderTemplate('{{ ../a }}', { a: 1 })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'parent-depth')
})
test('循环外取 @index:空串 + 警告', () => {
const result = renderTemplate('{{ @index }}', {})
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'missing')
})
test('#if:真值表按设计稿 §3.3 写死', () => {
const cases: Array<[unknown, boolean]> = [
[undefined, false],
[null, false],
[false, false],
['', false],
[0, false],
[Number.NaN, false],
[[], false],
[{}, false],
[' ', true],
['0', true],
[1, true],
[-1, true],
[[0], true],
[{ a: 1 }, true],
['a', true],
[true, true],
]
for (const [value, expected] of cases) {
const result = renderTemplate('{{#if v}}T{{else}}F{{/if}}', { v: value })
assert.equal(result.html, expected ? 'T' : 'F', `v=${String(value)}`)
}
})
test('#if:走不到的分支不求值(分支里的缺失路径不产生警告)', () => {
const result = renderTemplate('{{#if ok}}{{ a.b.c }}{{else}}{{ d.e }}{{/if}}', { ok: false, d: { e: 'hit' } })
assert.equal(result.html, 'hit')
assert.deepEqual(result.warnings, [])
})
test('#if/else:跨行写法的两条分支', () => {
const source = ['{{#if rows.length}}', ' 有 {{ rows.length }} 条', '{{else}}', ' 暂无数据', '{{/if}}'].join('\n')
assert.equal(renderTemplate(source, { rows: [1, 2] }).html, ' 有 2 条\n')
assert.equal(renderTemplate(source, { rows: [] }).html, ' 暂无数据\n')
})
test('#if 里的比较运算按非法路径处理 → 假(设计稿 §3.3)', () => {
const result = renderTemplate('{{#if rank > 3}}T{{else}}F{{/if}}', { rank: 9 })
assert.equal(result.html, 'F')
assert.equal(result.warnings[0].code, 'invalid-path')
})
test('standalone line:只有空白 + 块标记的行整行吞掉,缩进模板不留空行', () => {
const source = '<div>\n {{#each rows}}\n <span>{{ this }}</span>\n {{/each}}\n</div>'
const result = renderTemplate(source, { rows: ['a', 'b'] })
// {{#each}} / {{/each}} 那两行连缩进与换行一起消失;循环体自己那行的缩进保留
assert.equal(result.html, '<div>\n <span>a</span>\n <span>b</span>\n</div>')
})
test('standalone line:空数组时不留下循环那两行的空白', () => {
const source = '<div>\n {{#each rows}}\n <span>{{ this }}</span>\n {{/each}}\n</div>'
assert.equal(renderTemplate(source, { rows: [] }).html, '<div>\n</div>')
})
test('standalone line:行内块与插值行都不吞行', () => {
assert.equal(renderTemplate('x{{#if v}}y{{/if}}z', { v: true }).html, 'xyz')
assert.equal(renderTemplate('<div>\n {{ v }}\n</div>', { v: 'a' }).html, '<div>\n a\n</div>')
})
test('standalone line:块标记周围有其它字符就不吞行', () => {
const result = renderTemplate('<div>\n {{#if v}}x{{/if}}\n</div>', { v: true })
assert.equal(result.html, '<div>\n x\n</div>')
})
@@ -0,0 +1,104 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseTemplate, renderTemplate } from '../template'
// 结构错误口径:设计稿 §3.1 说「硬失败」,§9 说要降级、不能白屏,§10.1 要「结构化 ParseError(含位置)」。
// 这里的落法是:引擎不抛异常,把结构化错误(码 + 行列号 + 偏移)放在返回值里,由调用方决定降级。
test('未闭合的 {{#each}}:返回 unclosed-block,html 为空,不抛异常', () => {
const source = 'line1\n{{#each rows}}\n <span>{{ this }}</span>\n'
const result = renderTemplate(source, { rows: ['a'] })
assert.equal(result.error?.code, 'unclosed-block')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 1)
assert.equal(result.error?.offset, 6)
assert.equal(result.html, '')
assert.deepEqual(result.warnings, [])
})
test('未闭合的 {{#if}}:返回 unclosed-block,指向最外层没闭合的块', () => {
const source = '{{#if a}}\n{{#if b}}\nx\n{{/if}}\n'
const result = renderTemplate(source, { a: true, b: true })
assert.equal(result.error?.code, 'unclosed-block')
assert.equal(result.error?.line, 1)
assert.equal(result.html, '')
})
test('多出来的 {{/each}}:返回 unexpected-close', () => {
const result = renderTemplate('x\n{{/each}}', {})
assert.equal(result.error?.code, 'unexpected-close')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 1)
})
test('错配的 {{/if}}:返回 block-mismatch,消息里点出两个块名', () => {
const result = renderTemplate('{{#each rows}}x{{/if}}', { rows: [] })
assert.equal(result.error?.code, 'block-mismatch')
assert.ok(result.error?.message.includes('each'))
assert.ok(result.error?.message.includes('if'))
})
test('{{else}} 出现在 {{#each}} 内:返回 unexpected-else', () => {
const result = renderTemplate('{{#each rows}}a{{else}}b{{/each}}', { rows: [] })
assert.equal(result.error?.code, 'unexpected-else')
})
test('{{else}} 出现在块外:返回 unexpected-else', () => {
const result = renderTemplate('{{else}}', {})
assert.equal(result.error?.code, 'unexpected-else')
})
test('同一个 {{#if}} 里两个 {{else}}:返回 duplicate-else', () => {
const result = renderTemplate('{{#if a}}1{{else}}2{{else}}3{{/if}}', { a: true })
assert.equal(result.error?.code, 'duplicate-else')
})
test('{{ 不配对:返回 unclosed-tag 并给出精确位置', () => {
const result = renderTemplate('a\n b {{ x', {})
assert.equal(result.error?.code, 'unclosed-tag')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 4)
assert.equal(result.error?.offset, 5)
})
test('{{{ 不配对:返回 unclosed-tag', () => {
const result = renderTemplate('{{{ x }}', { x: 1 })
assert.equal(result.error?.code, 'unclosed-tag')
assert.equal(result.html, '')
})
test('不支持的块标记:返回 unknown-block', () => {
assert.equal(renderTemplate('{{#unless a}}x{{/unless}}', {}).error?.code, 'unknown-block')
assert.equal(renderTemplate('{{#each rows}}x{{/with}}', {}).error?.code, 'unknown-block')
})
test('块标记缺路径:返回 empty-block-path', () => {
assert.equal(renderTemplate('{{#if}}x{{/if}}', {}).error?.code, 'empty-block-path')
assert.equal(renderTemplate('{{#each}}x{{/each}}', {}).error?.code, 'empty-block-path')
})
test('结构错误一律不抛异常(设计稿 §9:模板问题不得打挂大屏)', () => {
const broken = ['{{#each rows}}', '{{/if}}', '{{', '{{{', '{{#unless a}}', '{{else}}', '{{#each rows}}{{/if}}']
for (const source of broken) {
assert.doesNotThrow(() => renderTemplate(source, { rows: [] }), source)
assert.ok(renderTemplate(source, { rows: [] }).error !== null, source)
}
})
test('解析通过的模板 error 为 null,ast 非空', () => {
const parsed = parseTemplate('{{ a }}')
assert.equal(parsed.error, null)
assert.equal(parsed.ast.length, 1)
assert.equal(parsed.ast[0].type, 'value')
const broken = parseTemplate('{{#each rows}}')
assert.equal(broken.error?.code, 'unclosed-block')
assert.deepEqual(broken.ast, [])
})
test('行号按实际换行统计(\\r\\n 模板也不偏)', () => {
const result = renderTemplate('a\r\nb\r\n{{#each rows}}\r\nx\r\n', { rows: [] })
assert.equal(result.error?.line, 3)
})
@@ -0,0 +1,132 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// M1 的验收目标(设计稿 §3.7、§11.1):把「社区积分榜」示例模板用一份构造数据渲染出来。
// 这里保留示例的缩进与块标记位置,顺带验证 standalone line 吞行不会在输出里留下空行。
const SOURCE = `<div class="wrap">
<h1>{{ settings.display.title }}</h1>
<div class="sub">{{ settings.event.name }} · 共 {{ standings.length }} 个单位</div>
{{#if standings.length}}
<div class="row head"><span class="rk">名次</span><span class="nm">单位</span><span></span><span class="pts">总分</span></div>
{{#each standings}}
<div class="row">
<span class="rk top{{ this.rank }}">{{ this.rank }}</span>
<span class="nm">
{{{ this.logoImg }}}
<span>{{ this.teamName }}</span>
</span>
<span>
{{#if this.rankChange}}<b class="{{ this.rankUpDownClass }}">{{ this.rankChangeText }}</b>{{/if}}
</span>
<span class="pts">{{ this.totalPoints }}</span>
</div>
{{/each}}
{{else}}
<div class="empty">暂无积分数据</div>
{{/if}}
</div>`
// logoImg / rankChangeText / rankUpDownClass 是设计稿 §4.3 说的「桥补的便利字段」,
// M1 只消费它们,不由引擎生成。
function createData() {
return {
settings: {
display: { title: '社区积分榜' },
event: { name: '第十二届社区运动会' },
},
standings: [
{
rank: 1,
teamName: '阳光社区',
totalPoints: 128,
rankChange: 2,
rankChangeText: '▲2',
rankUpDownClass: 'up',
logoImg: '<img src="/uploads/sun.png" alt="">',
},
{
rank: 2,
teamName: '晨曦社区',
totalPoints: 96,
rankChange: 0,
rankChangeText: '',
rankUpDownClass: '',
logoImg: '',
},
{
rank: 3,
teamName: 'A&B <社区>',
totalPoints: 88,
rankChange: null,
rankChangeText: '',
rankUpDownClass: '',
logoImg: '',
},
],
}
}
test('示例一:纯插值「社区积分榜」的关键片段', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.error, null)
assert.equal(result.html.includes('<h1>社区积分榜</h1>'), true, '标题插值')
assert.equal(result.html.includes('第十二届社区运动会 · 共 3 个单位'), true, '赛事名与数组长度')
assert.equal(result.html.includes('<span class="rk">名次</span>'), true, '有数据时走 #if 真分支的表头')
assert.equal(result.html.includes('<span class="rk top1">1</span>'), true, '名次参与 class 拼接')
assert.equal(result.html.includes('<b class="up">▲2</b>'), true, '上升箭头')
assert.equal(result.html.includes('<span class="pts">128</span>'), true, '总分')
assert.equal(result.html.includes('暂无积分数据'), false, '有数据时不输出空态')
})
test('示例一:三花括号的 logoImg 原样落成标签,文本字段仍然转义', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.html.includes('<img src="/uploads/sun.png" alt="">'), true, 'logoImg 不该被实体化')
assert.equal(result.html.includes('A&amp;B &lt;社区&gt;'), true, '单位名里的 & 与尖括号被转义')
assert.equal(result.html.includes('A&B <社区>'), false)
})
test('示例一:空值处渲染成空串,输出里不出现 undefined / null / [object Object]', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.html.includes('undefined'), false)
assert.equal(result.html.includes('[object Object]'), false)
assert.equal(result.html.includes('>null<'), false)
// 第三行 rankChange 为 null:不输出箭头 <b>,那一格是空串
const thirdRow = result.html.slice(result.html.indexOf('A&amp;B &lt;社区&gt;'))
assert.equal(thirdRow.includes('<b class'), false)
})
test('示例一:standings 为空时走 {{else}} 空态分支', () => {
const data = { ...createData(), standings: [] }
const result = renderTemplate(SOURCE, data)
assert.equal(result.html.includes('暂无积分数据'), true)
assert.equal(result.html.includes('名次'), false, '空态不该输出表头')
assert.equal(result.html.includes('共 0 个单位'), true)
assert.equal(result.error, null)
})
test('示例一:整体渲染不产生结构错误,警告只来自按设计缺失的字段', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.error, null)
for (const warning of result.warnings) {
assert.equal(warning.code, 'null')
assert.equal(warning.path, 'this.rankChange')
}
assert.equal(result.warnings.length, 1)
})
test('示例一:空结果里循环那两行不留下空白行', () => {
const data = { ...createData(), standings: [] }
const result = renderTemplate(SOURCE, data)
assert.equal(result.html.includes('\n \n'), false, 'standalone 块标记的行应当整行消失')
// 循环体整段没输出,接在空态分支前面的只有模板里那个空行
assert.equal(result.html.includes('\n <div class="empty">暂无积分数据</div>\n</div>'), true)
})
@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 设计稿 §10.1 的「边界/性能」组:单文件上限是 1 MiB(§6.2),引擎不能在这个量级上炸或卡死。
test('1 MiB 量级的模板能正常渲染', () => {
const filler = 'x'.repeat(1024)
const source = `${filler}\n`.repeat(1024) + '<b>{{ title }}</b>\n'
assert.ok(source.length > 1024 * 1024, '构造的模板要超过 1 MiB')
const result = renderTemplate(source, { title: '超长模板' })
assert.equal(result.error, null)
assert.equal(result.html.endsWith('<b>超长模板</b>\n'), true)
assert.deepEqual(result.warnings, [])
})
test('1000 项循环:结果正确,同一处缺失路径只留一条警告', () => {
const rows = Array.from({ length: 1000 }, (_, i) => ({ n: i }))
const result = renderTemplate('{{#each rows}}{{ this.n }};{{/each}}', { rows })
assert.equal(result.error, null)
assert.equal(result.html.startsWith('0;1;2;'), true)
assert.equal(result.html.endsWith('999;'), true)
const missing = renderTemplate('{{#each rows}}{{ this.absent }}{{/each}}', { rows })
assert.equal(missing.html, '')
assert.equal(missing.warnings.length, 1)
assert.equal(missing.warnings[0].count, 1000)
})
test('深嵌套 #each/#if(30 层)不炸,作用域逐层正确', () => {
const depth = 30
let source = ''
const root: Record<string, unknown> = { name: 'root' }
let cursor = root
for (let i = 0; i < depth; i += 1) {
source += `{{#each level${i}}}`
const inner: Record<string, unknown> = { name: `n${i}` }
cursor[`level${i}`] = [inner]
cursor = inner
}
source += '{{ this.name }}'
for (let i = 0; i < depth; i += 1) source += '{{/each}}'
const result = renderTemplate(source, root)
assert.equal(result.error, null)
assert.equal(result.html, `n${depth - 1}`)
})
@@ -0,0 +1,138 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 插值通道的取值与转义口径:设计稿 §3.1(路径)、§3.4(转义)、§3.5(未定义=空串 + 警告)。
// 引擎是纯函数,这里全部直接调 renderTemplate,不依赖 Vue / DOM。
test('插值:多级路径与数组下标', () => {
const data = { settings: { display: { title: '春季运动会' } }, standings: [{ teamName: '甲' }, { teamName: '乙' }] }
const result = renderTemplate('{{ settings.display.title }}|{{ standings.0.teamName }}|{{standings.1.teamName}}', data)
assert.equal(result.html, '春季运动会|甲|乙')
assert.equal(result.error, null)
assert.deepEqual(result.warnings, [])
})
test('插值:路径外的空白不参与取值', () => {
const result = renderTemplate('{{ v }}', { v: 'ok' })
assert.equal(result.html, 'ok')
})
test('未定义变量:渲染成空串,只报一条警告,不抛异常', () => {
const result = renderTemplate('<td>{{ settings.display.missing }}</td>', { settings: { display: {} } })
assert.equal(result.html, '<td></td>')
assert.equal(result.error, null)
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].code, 'missing')
assert.equal(result.warnings[0].path, 'settings.display.missing')
assert.equal(result.warnings[0].count, 1)
assert.equal(result.warnings[0].line, 1)
assert.ok(result.warnings[0].message.includes('settings.display.missing'))
assert.ok(!result.html.includes('undefined'))
})
test('深层缺失:中间节点不存在也是空串', () => {
const result = renderTemplate('{{ a.b.c }}', {})
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'missing')
})
test('null 中间节点:空串 + null 警告', () => {
const result = renderTemplate('{{ a.b.c }}', { a: { b: null } })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'null')
})
test('null 叶子:空串 + null 警告', () => {
const result = renderTemplate('[{{ a }}]', { a: null })
assert.equal(result.html, '[]')
assert.equal(result.warnings[0].code, 'null')
})
test('对数字/字符串取属性:空串 + 警告(设计稿 §3.5 的例子 version.x)', () => {
const numberCase = renderTemplate('{{ version.x }}', { version: 3 })
assert.equal(numberCase.html, '')
assert.equal(numberCase.warnings[0].code, 'missing')
const stringCase = renderTemplate('{{ name.length }}', { name: 'abc' })
assert.equal(stringCase.html, '')
assert.equal(stringCase.warnings[0].code, 'missing')
})
test('数组越界与用非下标取数组:空串 + 警告', () => {
const outOfRange = renderTemplate('{{ rows.9 }}', { rows: ['a'] })
assert.equal(outOfRange.html, '')
assert.equal(outOfRange.warnings[0].code, 'missing')
const nonIndex = renderTemplate('{{ rows.foo }}', { rows: ['a'] })
assert.equal(nonIndex.html, '')
assert.equal(nonIndex.warnings[0].code, 'missing')
})
test('数组的 length 可以取', () => {
const result = renderTemplate('共 {{ rows.length }} 条', { rows: ['a', 'b'] })
assert.equal(result.html, '共 2 条')
assert.deepEqual(result.warnings, [])
})
test('同一个缺失路径出现多次:合并成一条警告并累加 count', () => {
const result = renderTemplate('{{ a }}-{{ a }}-{{ a }}', {})
assert.equal(result.html, '--')
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].count, 3)
})
test('默认转义:& < > " \' 全部实体化,中文原样', () => {
const value = `<script>alert("x&y")</script>'中文`
const result = renderTemplate('{{ v }}', { v: value })
assert.equal(result.html, '&lt;script&gt;alert(&quot;x&amp;y&quot;)&lt;/script&gt;&#39;中文')
assert.ok(!result.html.includes('<'), '转义后不该再出现 <')
})
test('三花括号与 {{& }} 原样输出(设计稿 §3.4)', () => {
const data = { logoImg: '<img src="/uploads/a.png" alt="">' }
assert.equal(renderTemplate('{{{ logoImg }}}', data).html, '<img src="/uploads/a.png" alt="">')
assert.equal(renderTemplate('{{& logoImg }}', data).html, '<img src="/uploads/a.png" alt="">')
// 默认通道仍然转义,两者不能混为一谈
assert.equal(renderTemplate('{{ logoImg }}', data).html, '&lt;img src=&quot;/uploads/a.png&quot; alt=&quot;&quot;&gt;')
})
test('数字与布尔照原样渲染', () => {
assert.equal(renderTemplate('{{ n }}', { n: 0 }).html, '0')
assert.equal(renderTemplate('{{ n }}', { n: -1.5 }).html, '-1.5')
assert.equal(renderTemplate('{{ b }}', { b: true }).html, 'true')
assert.equal(renderTemplate('{{ b }}', { b: false }).html, 'false')
})
test('对象/数组插值:空串 + not-scalar 警告,不把 [object Object] 泼到屏上', () => {
const objectCase = renderTemplate('{{ settings }}', { settings: { a: 1 } })
assert.equal(objectCase.html, '')
assert.equal(objectCase.warnings[0].code, 'not-scalar')
const arrayCase = renderTemplate('{{ rows }}', { rows: [1, 2] })
assert.equal(arrayCase.html, '')
assert.equal(arrayCase.warnings[0].code, 'not-scalar')
})
test('不支持的写法(运算符/过滤器/空标签):空串 + invalid-path 警告,不抛异常', () => {
const cases = ['{{ a + 1 }}', '{{ a | b }}', '{{ a.b. }}', '{{ }}', '{{ @foo }}', '{{ a/b }}']
for (const source of cases) {
const result = renderTemplate(source, { a: 1, b: 2 })
assert.equal(result.html, '', source)
assert.equal(result.error, null, source)
assert.equal(result.warnings[0]?.code, 'invalid-path', source)
}
})
test('渲染不抛异常:各种奇怪的数据对象都只是空串 + 警告', () => {
const sources = ['{{ a.b }}', '{{ a }}', '{{ @index }}', '{{ ../a }}', '{{#if a}}{{ a }}{{/if}}']
const datas: unknown[] = [undefined, null, 0, 'str', [], { a: null }, { a: () => 1 }]
for (const source of sources) {
for (const data of datas) {
assert.doesNotThrow(() => renderTemplate(source, data), `${source} / ${String(data)}`)
}
}
})
+47 -1
View File
@@ -8,7 +8,10 @@ import type {
AthleteInput,
AthleteStanding,
AuditLog,
AuditLogQuery,
BackupResult,
BackupRestoreInput,
BackupRestoreResult,
CurrentUser,
DisplayCommand,
EntryHeatPayload,
@@ -215,6 +218,13 @@ export interface AthleteBatchResult {
}
export interface TeamsApi extends CrudApi<Team, TeamInput> {
/**
* 单位列表。与 CrudApi.list 的差别只有 includeDeleted 一个开关。
*
* `includeDeleted: true` 时连「彻底删除」(对界面不可见)的单位一并返回,供管理端
* 把它们翻出来恢复;服务端只放 admin 用这个开关,其余角色 403。
*/
list(query?: TeamsListQuery): Promise<Team[]>
/**
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
*
@@ -223,6 +233,25 @@ export interface TeamsApi extends CrudApi<Team, TeamInput> {
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
*/
purge(id: number): Promise<void>
/**
* 恢复被彻底删除的单位(`POST /api/teams/{id}/restore`)。
*
* 只是把「不可见」的标记撤掉:报名、成绩与积分一行没动,所以不会重算、也不会推榜单。
* 幂等——单位本来就没被隐藏时照样 200,`restored` 为 false;单位不存在时 404。
*/
restore(id: number): Promise<TeamRestoreResult>
}
/** 单位列表的查询开关。 */
export interface TeamsListQuery {
/** true 时连「彻底删除」的单位一并返回(仅 admin)。 */
includeDeleted?: boolean
}
/** 恢复被彻底删除单位的结果;restored 为 false 表示它本来就没被隐藏(幂等命中)。 */
export interface TeamRestoreResult {
team: Team
restored: boolean
}
export interface EntriesApi {
@@ -321,7 +350,7 @@ export interface ExportApi {
}
export interface AuditApi {
list(query?: PageQuery & { entityType?: string; userId?: number }): Promise<Page<AuditLog>>
list(query?: AuditLogQuery): Promise<Page<AuditLog>>
}
export interface RulesApi {
@@ -333,6 +362,14 @@ export interface RulesApi {
export interface SystemApi {
backup(): Promise<BackupResult>
/**
* 用一份备份恢复当前库(`POST /api/backup/restore`,仅 admin)。
*
* 这是停机动作:服务端会先留一份恢复前安全副本,再换掉库文件,然后优雅退出、
* 由 Console 拉起。调用方拿到 `restarting: true` 后必须轮询健康检查等服务端回来,
* 不能像普通写操作那样立刻认为已经生效(与切换赛事同一套等待逻辑)。
*/
restore(input: BackupRestoreInput): Promise<BackupRestoreResult>
}
/**
@@ -429,8 +466,15 @@ function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBat
function createTeamsApi(http: Http): TeamsApi {
return {
...createCrudApi<Team, TeamInput>(http, '/teams'),
// 默认列表不带查询串(服务端对非 admin 的 includeDeleted 回 403,不能默认带上)。
list: (query) =>
http.request<Team[]>('/teams', {
query: query?.includeDeleted === true ? { includeDeleted: 1 } : undefined,
}),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。
purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }),
// 恢复是写操作:服务端按 X-Request-Id 幂等回放,重复点也只会生效一次。
restore: (id) => http.request<TeamRestoreResult>(`/teams/${id}/restore`, { method: 'POST' }),
}
}
@@ -743,6 +787,8 @@ export class ApiClient {
this.rules = createRulesApi(http)
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
// 恢复要走幂等(X-Request-Id):现场断网重试时,重复请求不能把库换两遍。
restore: (input) => http.request<BackupRestoreResult>('/backup/restore', { method: 'POST', body: input }),
}
this.projects = createProjectsApi(http)
+72
View File
@@ -39,3 +39,75 @@ test('401 AUTH_REQUIRED 触发 onUnauthorized', async () => {
})
assert.equal(called, 1)
})
// 恢复是停机动作:客户端只把 file + kind 原样发给 /api/backup/restore,
// 拿到 restarting 回执后由页面去等服务端回来(不能在这里就当成已生效)。
// kind 传的是「要覆盖的那份库」(列表里的 dbKind),安全副本也照此传。
test('system.restore 提交 file + kind 并返回回执', async () => {
let url = ''
let method: string | undefined
let body: Record<string, unknown> = {}
const client = new ApiClient({
fetchImpl: async (input, init) => {
url = String(input)
method = init?.method
body = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(
JSON.stringify({
ok: true,
data: {
kind: 'safety',
dbKind: 'event',
kindLabel: '赛事库',
file: 'pre-restore-event-20260911-120000.db',
target: '/data/event_projs/default/event.db',
safetyCopy: 'pre-restore-event-20260911-130000.db',
safetyCopyPath: '/data/backups/pre-restore-event-20260911-130000.db',
prunedSafetyCopies: 2,
restarting: true,
message: '已用 pre-restore-event-20260911-120000.db 恢复赛事库。',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
},
})
const result = await client.system.restore({
file: 'pre-restore-event-20260911-120000.db',
kind: 'event',
})
assert.equal(url, '/api/backup/restore')
assert.equal(method, 'POST')
assert.equal(body.file, 'pre-restore-event-20260911-120000.db')
assert.equal(body.kind, 'event')
assert.equal(result.restarting, true)
assert.equal(result.kind, 'safety')
assert.equal(result.dbKind, 'event')
assert.equal(result.safetyCopy, 'pre-restore-event-20260911-130000.db')
assert.equal(result.prunedSafetyCopies, 2)
})
// 坏文件 / 选错库都是服务端的真实回答(400),必须原样抛给操作员,
// 不能降级成「已恢复」。
test('system.restore 把 400 原样抛出', async () => {
const client = new ApiClient({
fetchImpl: async () =>
new Response(
JSON.stringify({
ok: false,
error: { code: 'VALIDATION_FAILED', message: '备份文件不可用:缺少关键表 teams' },
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
})
await assert.rejects(client.system.restore({ file: 'event-x.db', kind: 'event' }), (error: unknown) => {
return (
error instanceof ApiError &&
error.code === 'VALIDATION_FAILED' &&
error.message.includes('备份文件不可用')
)
})
})
+49
View File
@@ -17,6 +17,11 @@ export interface Team {
color: string | null
sortOrder: number
active: boolean
/**
* 非空表示该单位已被「彻底删除」(假删除:默认列表不返回它,报名与成绩一行没动);
* 只有 admin 用 `list({ includeDeleted: true })` 才拿得到这类单位,用来恢复它们。
*/
deletedAt?: string | null
}
export type TeamInput = Omit<Team, 'id'>
@@ -273,6 +278,7 @@ export interface DraftResultInput {
unit?: string
isTie?: boolean
status?: ResultStatus
/** 服务端已拒绝非空值(400):引擎不读它,加减分请走「积分调整」。 */
pointsOverride?: number | null
note?: string | null
}
@@ -515,6 +521,42 @@ export interface BackupResult {
createdAt: string
}
/**
* 恢复请求体(POST /api/backup/restore)。
*
* `file` 只接受备份目录里的裸文件名;`kind` 是**要覆盖的那份库**
* (取备份列表里的 `dbKind`)——安全副本的性质是 `safety`,但它同样属于某一份库,
* 拿它退回上一个时间点是正当操作,所以请求传的仍是 system / event。
*/
export interface BackupRestoreInput {
file: string
kind: 'system' | 'event'
}
/**
* 恢复被接受后的回执。
*
* `restarting` 为 true 表示服务端已经换好库文件、正在优雅退出,随后由 Console 拉起;
* `safetyCopy` 是恢复前的安全副本文件名,恢复选错时间点时用它退回。
*/
export interface BackupRestoreResult {
/** 这次用的文件的性质:system / event / safety(安全副本)。 */
kind: 'system' | 'event' | 'safety'
/** 被恢复的那份库。 */
dbKind: 'system' | 'event'
kindLabel: string
file: string
target: string
safetyCopy: string
safetyCopyPath: string
/** 本次恢复顺带清理掉的旧安全副本份数(每类库保留最近 5 份)。 */
prunedSafetyCopies: number
restarting: boolean
message: string
/** 非空表示恢复已经完成、但有话要说(目前只用于审计写入失败)。 */
warning?: string
}
/**
* 一项可选字体(GET /api/fonts)。
*
@@ -691,6 +733,13 @@ export interface PageQuery {
size?: number
}
/** 审计日志查询:action 为子串模糊匹配,三个过滤条件可组合。 */
export interface AuditLogQuery extends PageQuery {
entityType?: string
userId?: number
action?: string
}
export interface Page<T> {
items: T[]
total: number