From 53a0cb225ca313404f7171d8d208f39909841b88 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 11 Sep 2026 19:26:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=87=E4=BB=BD=E6=81=A2=E5=A4=8D=20?= =?UTF-8?q?/=20=E5=9B=BE=E7=89=87=E6=B8=85=E7=90=86=20/=20=E5=8D=95?= =?UTF-8?q?=E4=BD=8D=E6=81=A2=E5=A4=8D=20/=20=E5=AE=A1=E8=AE=A1=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=20/=20=E6=A8=A1=E6=9D=BF=E5=BC=95=E6=93=8E=20M1=20+?= =?UTF-8?q?=20=E4=B8=A4=E4=B8=AA=E5=AF=BC=E5=85=A5=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷: **新增能力** - 备份恢复:`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。 --- VERSION | 2 +- deploy/backup.bat | 52 +- docs/上线检查清单.md | 12 +- docs/使用手册.md | 57 +- docs/常见问题.md | 38 +- docs/报名名单导入规范.md | 2 +- docs/部署指南.md | 35 +- server/internal/domain/errors.go | 3 + server/internal/domain/helpers_test.go | 3 +- server/internal/domain/matrix_test.go | 33 - server/internal/domain/records.go | 26 +- server/internal/domain/records_test.go | 30 - server/internal/domain/scoring.go | 56 +- server/internal/domain/scoring_test.go | 58 +- server/internal/domain/standings.go | 4 +- server/internal/domain/types.go | 48 +- server/internal/domain/types_test.go | 45 +- server/internal/export/backup.go | 45 +- server/internal/export/backup_restore.go | 480 ++++++++++++++ server/internal/export/backup_restore_test.go | 447 +++++++++++++ server/internal/export/basedata.go | 13 - .../httpapi/audit_action_filter_test.go | 86 +++ .../httpapi/backup_restore_safety_test.go | 180 ++++++ .../internal/httpapi/backup_restore_test.go | 325 ++++++++++ server/internal/httpapi/enums.go | 15 - .../internal/httpapi/enums_internal_test.go | 181 ------ .../internal/httpapi/export_basedata_test.go | 608 ++++++++++++++++++ server/internal/httpapi/export_handlers.go | 265 +++++++- server/internal/httpapi/handlers_config.go | 290 +++++++++ .../internal/httpapi/handlers_config_test.go | 350 ++++++++++ server/internal/httpapi/handlers_results.go | 10 +- server/internal/httpapi/payload.go | 12 + .../httpapi/points_override_reject_test.go | 71 ++ server/internal/httpapi/realtime.go | 33 + .../internal/httpapi/realtime_closed_test.go | 170 +++++ server/internal/httpapi/repo.go | 77 +++ server/internal/httpapi/repo_events.go | 119 ---- server/internal/httpapi/repo_misc.go | 15 +- server/internal/httpapi/response.go | 2 + server/internal/httpapi/server.go | 11 + server/internal/httpapi/team_restore_test.go | 296 +++++++++ server/internal/rules/parse_test.go | 4 +- server/internal/store/store_test.go | 65 +- .../system/0006_drop_recent_projects.sql | 3 + .../components/baseData/TeamsSection.vue | 178 ++++- .../admin/components/system/BackupPanel.vue | 175 ++++- .../components/system/ImageAssetsPanel.vue | 306 +++++++++ web/src/admin/components/system/systemApi.ts | 120 +++- web/src/admin/pages/LogsPage.vue | 60 +- web/src/admin/pages/SystemPage.vue | 6 +- web/src/admin/tests/image-assets-api.test.ts | 116 ++++ web/src/admin/tests/team-restore-api.test.ts | 152 +++++ web/src/display/template.ts | 562 ++++++++++++++++ web/src/display/tests/template-blocks.test.ts | 141 ++++ web/src/display/tests/template-errors.test.ts | 104 +++ .../display/tests/template-example.test.ts | 132 ++++ web/src/display/tests/template-limits.test.ts | 49 ++ web/src/display/tests/template-render.test.ts | 138 ++++ web/src/shared/api.ts | 48 +- web/src/shared/tests/api.test.ts | 72 +++ web/src/shared/types.ts | 49 ++ 61 files changed, 6386 insertions(+), 699 deletions(-) create mode 100644 server/internal/export/backup_restore.go create mode 100644 server/internal/export/backup_restore_test.go create mode 100644 server/internal/httpapi/audit_action_filter_test.go create mode 100644 server/internal/httpapi/backup_restore_safety_test.go create mode 100644 server/internal/httpapi/backup_restore_test.go delete mode 100644 server/internal/httpapi/enums_internal_test.go create mode 100644 server/internal/httpapi/points_override_reject_test.go create mode 100644 server/internal/httpapi/realtime_closed_test.go create mode 100644 server/internal/httpapi/team_restore_test.go create mode 100644 server/migrations/system/0006_drop_recent_projects.sql create mode 100644 web/src/admin/components/system/ImageAssetsPanel.vue create mode 100644 web/src/admin/tests/image-assets-api.test.ts create mode 100644 web/src/admin/tests/team-restore-api.test.ts create mode 100644 web/src/display/template.ts create mode 100644 web/src/display/tests/template-blocks.test.ts create mode 100644 web/src/display/tests/template-errors.test.ts create mode 100644 web/src/display/tests/template-example.test.ts create mode 100644 web/src/display/tests/template-limits.test.ts create mode 100644 web/src/display/tests/template-render.test.ts diff --git a/VERSION b/VERSION index cbeafc9..4704f87 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.1.6 +dev_0.1.7 diff --git a/deploy/backup.bat b/deploy/backup.bat index 013a5fc..953eba8 100644 --- a/deploy/backup.bat +++ b/deploy/backup.bat @@ -10,9 +10,18 @@ REM The data dir is the one the Console uses, i.e. %LOCALAPPDATA%\ScoreboardCon REM (falling back to \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 \event_projs\\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 \event_projs\\uploads\ and are +REM referenced from the DB by path, so this script also zips each +REM project's uploads\ into \backups\ next to its snapshots. +REM Logs under \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 diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 492d8ff..1b1860a 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -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. 现场流程演练(建议至少一次) diff --git a/docs/使用手册.md b/docs/使用手册.md index 37e2b2a..0ca2bb9 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -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. 修改密码 diff --git a/docs/常见问题.md b/docs/常见问题.md index b00c2be..9721d0d 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -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\`。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index 8ba2235..79f4aff 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -123,7 +123,7 @@ | 情况 | 系统怎么处理 | |---|---| | 表里的号码布在系统里已有 | 认作**同一个人**;若表里的姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上,会进「冲突」列表等你逐条决定(保留哪一侧),该行本轮不写入 | -| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:
· 找不到 → 新建人员档案;
· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;
· 恰好一人、且他**已有号码布** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);
· 恰好一人、但他已有号码布**且与表里不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;
· 多人 → 分不清是谁,进「冲突」列表等逐条决定 | +| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:
· 找不到 → 新建人员档案;
· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;
· 恰好一人、且他**已有号码布**,但表里**号码布为空或与他相同** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);
· 恰好一人、他已有号码布、**且表里给的号码布与他不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;
· 多人 → 分不清是谁,进「冲突」列表等逐条决定 | > 「同名的另一个人」多半是号码布写错了。收到这条提醒时,先核对新号码布是不是本人:是别人(真的同名),确认无误即可;是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来,并把多出来的那条新档案删掉。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 89ad472..3670bc9 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -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)。赛前导出一份留底,现场改乱了可以导回对照。 diff --git a/server/internal/domain/errors.go b/server/internal/domain/errors.go index ae4013b..dd9f5ca 100644 --- a/server/internal/domain/errors.go +++ b/server/internal/domain/errors.go @@ -13,4 +13,7 @@ var ( ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用") ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由") ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作") + // ErrPointsOverrideUnsupported:成绩上的 pointsOverride 不被引擎读取。 + // 与其静默忽略(设置成功但毫无效果),不如显式报错;加减分请走人工积分调整。 + ErrPointsOverrideUnsupported = errors.New("domain: 成绩不接受 pointsOverride,请改用人工积分调整") ) diff --git a/server/internal/domain/helpers_test.go b/server/internal/domain/helpers_test.go index 74f620b..b6c85d4 100644 --- a/server/internal/domain/helpers_test.go +++ b/server/internal/domain/helpers_test.go @@ -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 == "" { diff --git a/server/internal/domain/matrix_test.go b/server/internal/domain/matrix_test.go index 1f3dc8f..1b22358 100644 --- a/server/internal/domain/matrix_test.go +++ b/server/internal/domain/matrix_test.go @@ -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) - } -} diff --git a/server/internal/domain/records.go b/server/internal/domain/records.go index d0cdf22..1fe5ec2 100644 --- a/server/internal/domain/records.go +++ b/server/internal/domain/records.go @@ -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 } diff --git a/server/internal/domain/records_test.go b/server/internal/domain/records_test.go index 2d7a5a9..737cbab 100644 --- a/server/internal/domain/records_test.go +++ b/server/internal/domain/records_test.go @@ -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 统计一次计分产出的破纪录加分总和。 diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index e4e9722..51293f4 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -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}} } diff --git a/server/internal/domain/scoring_test.go b/server/internal/domain/scoring_test.go index 1fc6368..a223287 100644 --- a/server/internal/domain/scoring_test.go +++ b/server/internal/domain/scoring_test.go @@ -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) } } diff --git a/server/internal/domain/standings.go b/server/internal/domain/standings.go index 84ba86b..b29aaaa 100644 --- a/server/internal/domain/standings.go +++ b/server/internal/domain/standings.go @@ -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) { diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index ec86cb8..9dd4b41 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -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 } diff --git a/server/internal/domain/types_test.go b/server/internal/domain/types_test.go index 40d383d..a57fafb 100644 --- a/server/internal/domain/types_test.go +++ b/server/internal/domain/types_test.go @@ -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) } } diff --git a/server/internal/export/backup.go b/server/internal/export/backup.go index b196470..75d8b14 100644 --- a/server/internal/export/backup.go +++ b/server/internal/export/backup.go @@ -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) { diff --git a/server/internal/export/backup_restore.go b/server/internal/export/backup_restore.go new file mode 100644 index 0000000..9bd41b7 --- /dev/null +++ b/server/internal/export/backup_restore.go @@ -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 +} diff --git a/server/internal/export/backup_restore_test.go b/server/internal/export/backup_restore_test.go new file mode 100644 index 0000000..45873e9 --- /dev/null +++ b/server/internal/export/backup_restore_test.go @@ -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) + } +} diff --git a/server/internal/export/basedata.go b/server/internal/export/basedata.go index 45bfcf5..4200ffb 100644 --- a/server/internal/export/basedata.go +++ b/server/internal/export/basedata.go @@ -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) diff --git a/server/internal/httpapi/audit_action_filter_test.go b/server/internal/httpapi/audit_action_filter_test.go new file mode 100644 index 0000000..fe81cfd --- /dev/null +++ b/server/internal/httpapi/audit_action_filter_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/backup_restore_safety_test.go b/server/internal/httpapi/backup_restore_safety_test.go new file mode 100644 index 0000000..c8e144e --- /dev/null +++ b/server/internal/httpapi/backup_restore_safety_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/backup_restore_test.go b/server/internal/httpapi/backup_restore_test.go new file mode 100644 index 0000000..b9133d3 --- /dev/null +++ b/server/internal/httpapi/backup_restore_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/enums.go b/server/internal/httpapi/enums.go index 43be06e..a29b749 100644 --- a/server/internal/httpapi/enums.go +++ b/server/internal/httpapi/enums.go @@ -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) { diff --git a/server/internal/httpapi/enums_internal_test.go b/server/internal/httpapi/enums_internal_test.go deleted file mode 100644 index ef76804..0000000 --- a/server/internal/httpapi/enums_internal_test.go +++ /dev/null @@ -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) - } -} diff --git a/server/internal/httpapi/export_basedata_test.go b/server/internal/httpapi/export_basedata_test.go index 74baa24..e49aaca 100644 --- a/server/internal/httpapi/export_basedata_test.go +++ b/server/internal/httpapi/export_basedata_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index f4e8d68..797fb4b 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -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: ") +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index ed92442..d2a9f3b 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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 +} diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index dce5711..333f494 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index 0721fa8..ded6de9 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -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) { diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index 093abaf..ebe1786 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -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"` +} diff --git a/server/internal/httpapi/points_override_reject_test.go b/server/internal/httpapi/points_override_reject_test.go new file mode 100644 index 0000000..96ea5b0 --- /dev/null +++ b/server/internal/httpapi/points_override_reject_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go index 97e3407..916414d 100644 --- a/server/internal/httpapi/realtime.go +++ b/server/internal/httpapi/realtime.go @@ -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 } diff --git a/server/internal/httpapi/realtime_closed_test.go b/server/internal/httpapi/realtime_closed_test.go new file mode 100644 index 0000000..8b7752d --- /dev/null +++ b/server/internal/httpapi/realtime_closed_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index b7e7bf9..399cd50 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -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` diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index f5d7b92..083ed2b 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -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 diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 3199767..7d81b00 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -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 节)。 diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go index 1e8a579..463d53d 100644 --- a/server/internal/httpapi/response.go +++ b/server/internal/httpapi/response.go @@ -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): diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index d189033..828ca82 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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 不带会话。字体目录比图片多一层,静态路由必须单独注册。 diff --git a/server/internal/httpapi/team_restore_test.go b/server/internal/httpapi/team_restore_test.go new file mode 100644 index 0000000..8c90ef8 --- /dev/null +++ b/server/internal/httpapi/team_restore_test.go @@ -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) + } +} diff --git a/server/internal/rules/parse_test.go b/server/internal/rules/parse_test.go index 7cc1eb1..246ee72 100644 --- a/server/internal/rules/parse_test.go +++ b/server/internal/rules/parse_test.go @@ -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 { diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index 04d2da4..83efccf 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -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 { diff --git a/server/migrations/system/0006_drop_recent_projects.sql b/server/migrations/system/0006_drop_recent_projects.sql new file mode 100644 index 0000000..3269b9d --- /dev/null +++ b/server/migrations/system/0006_drop_recent_projects.sql @@ -0,0 +1,3 @@ +-- 「最近打开的赛事」已改用 <数据目录>/active_project.txt 记录, +-- recent_projects 表自创建起就没有任何读写路径,是死表;显式删除以免后来者误以为它仍在使用。 +DROP TABLE IF EXISTS recent_projects; diff --git a/web/src/admin/components/baseData/TeamsSection.vue b/web/src/admin/components/baseData/TeamsSection.vue index 4359e36..b5a38c8 100644 --- a/web/src/admin/components/baseData/TeamsSection.vue +++ b/web/src/admin/components/baseData/TeamsSection.vue @@ -1,5 +1,5 @@ @@ -381,6 +523,10 @@ async function confirmPurge(): Promise { .danger-text { color: var(--mx-red); } +.bd-show-deleted { + white-space: nowrap; + font-size: 13px; +} .purge-error { display: flex; align-items: flex-start; diff --git a/web/src/admin/components/system/BackupPanel.vue b/web/src/admin/components/system/BackupPanel.vue index 284b1db..f29ee8f 100644 --- a/web/src/admin/components/system/BackupPanel.vue +++ b/web/src/admin/components/system/BackupPanel.vue @@ -1,9 +1,12 @@ + + + + diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts index 92de09c..c30c182 100644 --- a/web/src/admin/components/system/systemApi.ts +++ b/web/src/admin/components/system/systemApi.ts @@ -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, 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(`/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 { + const body = await requestJson('/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 { + await requestJson(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' }) + }, + + /** 批量删除明确列出的孤儿;服务端逐条回报,被引用的条目不会删。 */ + async removeOrphans(names: string[]): Promise { + const body = await requestJson('/uploads/orphans/delete', { + method: 'POST', + json: { names }, + }) + return toOrphanDeleteResult(body) + }, +} diff --git a/web/src/admin/pages/LogsPage.vue b/web/src/admin/pages/LogsPage.vue index a186a0c..79cec12 100644 --- a/web/src/admin/pages/LogsPage.vue +++ b/web/src/admin/pages/LogsPage.vue @@ -1,6 +1,6 @@ '中文` + const result = renderTemplate('{{ v }}', { v: value }) + + assert.equal(result.html, '<script>alert("x&y")</script>'中文') + assert.ok(!result.html.includes('<'), '转义后不该再出现 <') +}) + +test('三花括号与 {{& }} 原样输出(设计稿 §3.4)', () => { + const data = { logoImg: '' } + assert.equal(renderTemplate('{{{ logoImg }}}', data).html, '') + assert.equal(renderTemplate('{{& logoImg }}', data).html, '') + // 默认通道仍然转义,两者不能混为一谈 + assert.equal(renderTemplate('{{ logoImg }}', data).html, '<img src="/uploads/a.png" alt="">') +}) + +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)}`) + } + } +}) diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index a287f7a..1283e05 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -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 { + /** + * 单位列表。与 CrudApi.list 的差别只有 includeDeleted 一个开关。 + * + * `includeDeleted: true` 时连「彻底删除」(对界面不可见)的单位一并返回,供管理端 + * 把它们翻出来恢复;服务端只放 admin 用这个开关,其余角色 403。 + */ + list(query?: TeamsListQuery): Promise /** * 彻底删除(`DELETE /api/teams/{id}?purge=1`)。 * @@ -223,6 +233,25 @@ export interface TeamsApi extends CrudApi { * 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。 */ purge(id: number): Promise + /** + * 恢复被彻底删除的单位(`POST /api/teams/{id}/restore`)。 + * + * 只是把「不可见」的标记撤掉:报名、成绩与积分一行没动,所以不会重算、也不会推榜单。 + * 幂等——单位本来就没被隐藏时照样 200,`restored` 为 false;单位不存在时 404。 + */ + restore(id: number): Promise +} + +/** 单位列表的查询开关。 */ +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> + list(query?: AuditLogQuery): Promise> } export interface RulesApi { @@ -333,6 +362,14 @@ export interface RulesApi { export interface SystemApi { backup(): Promise + /** + * 用一份备份恢复当前库(`POST /api/backup/restore`,仅 admin)。 + * + * 这是停机动作:服务端会先留一份恢复前安全副本,再换掉库文件,然后优雅退出、 + * 由 Console 拉起。调用方拿到 `restarting: true` 后必须轮询健康检查等服务端回来, + * 不能像普通写操作那样立刻认为已经生效(与切换赛事同一套等待逻辑)。 + */ + restore(input: BackupRestoreInput): Promise } /** @@ -429,8 +466,15 @@ function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBat function createTeamsApi(http: Http): TeamsApi { return { ...createCrudApi(http, '/teams'), + // 默认列表不带查询串(服务端对非 admin 的 includeDeleted 回 403,不能默认带上)。 + list: (query) => + http.request('/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(`/teams/${id}/restore`, { method: 'POST' }), } } @@ -743,6 +787,8 @@ export class ApiClient { this.rules = createRulesApi(http) this.system = { backup: () => http.request('/backup', { method: 'POST' }), + // 恢复要走幂等(X-Request-Id):现场断网重试时,重复请求不能把库换两遍。 + restore: (input) => http.request('/backup/restore', { method: 'POST', body: input }), } this.projects = createProjectsApi(http) diff --git a/web/src/shared/tests/api.test.ts b/web/src/shared/tests/api.test.ts index b9696d8..2584cb1 100644 --- a/web/src/shared/tests/api.test.ts +++ b/web/src/shared/tests/api.test.ts @@ -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 = {} + const client = new ApiClient({ + fetchImpl: async (input, init) => { + url = String(input) + method = init?.method + body = JSON.parse(String(init?.body)) as Record + 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('备份文件不可用') + ) + }) +}) diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index f4ca142..bc216e3 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -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 @@ -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 { items: T[] total: number