From 6841cf49aa4cb2d60b6ad7568cf343c66ab82123 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 11 Sep 2026 23:46:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(p2):=20=E6=8C=89=E5=AE=A1=E6=9F=A5=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E4=BF=AE=20103=20=E6=9D=A1=E3=80=8C=E5=BB=BA=E8=AE=AE?= =?UTF-8?q?=E4=BF=AE=E3=80=8D+=20=E5=A5=96=E7=89=8C=E6=A6=9C=E6=94=B9?= =?UTF-8?q?=E5=90=8D=20+=20=E4=B8=A4=E4=B8=AA=E5=AE=88=E5=8D=AB=EF=BC=88de?= =?UTF-8?q?v=5F0.2.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 奖牌榜改名(主人要求) 「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与 下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是 大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名, 老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端 路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。 ## P2 清单(123 条,103 条标「建议修」) 按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核 代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。 主要交付(按风险从高到低): - 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、 「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB 上限与 413(A01–A06、D02、D03 批量上限 500) - 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说 「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭 - 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、 CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、 反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、 过期会话定期清理、登出补审计、登录限流可配 - 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、 单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、 基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、 报名导入换单位给明确提示、勾选报名有冲突不再假报成功 - 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限 20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、 导出件路由与文件名契约补全 - 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、 基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、 访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容 - Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来 会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试 ## 测试 服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、 429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等) 前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。 另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer 轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。 ## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区) - `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/ watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因) - `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个 管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败 ## 文档 - `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀 与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF) - 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步 --- VERSION | 2 +- console/README.md | 13 +- console/main/datadir.js | 10 + console/main/datadir.test.js | 35 ++ console/main/index.js | 17 +- console/main/service.js | 129 +++-- console/main/service.test.js | 485 ++++++++++++++++++ console/package.json | 2 +- console/renderer/app.js | 35 +- deploy/backup.bat | 27 +- docs/上线检查清单.md | 6 +- docs/使用手册.md | 47 +- docs/升级说明.md | 57 +- docs/常见问题.md | 22 +- docs/报名名单导入规范.md | 2 +- docs/部署指南.md | 15 +- server/internal/auth/auth.go | 195 +++++-- server/internal/auth/session_cookie_test.go | 132 +++++ .../internal/auth/session_integrity_test.go | 323 ++++++++++++ server/internal/domain/errors.go | 16 +- server/internal/domain/matrix_test.go | 3 +- server/internal/domain/medal_channels_test.go | 2 +- .../internal/domain/scoring_boundary_test.go | 211 ++++++++ server/internal/domain/standings.go | 4 +- server/internal/domain/standings_test.go | 2 +- server/internal/domain/types.go | 2 +- server/internal/export/athletes.go | 1 + server/internal/export/backup.go | 10 +- server/internal/export/basedata.go | 14 +- server/internal/export/basedata_test.go | 49 ++ server/internal/export/entries.go | 1 + server/internal/export/event.go | 23 +- server/internal/export/event_test.go | 149 +++++- server/internal/export/flat.go | 1 + server/internal/export/flat_test.go | 2 +- server/internal/export/load.go | 58 ++- server/internal/export/medals.go | 17 +- server/internal/export/medals_test.go | 20 +- server/internal/export/standings.go | 8 +- server/internal/export/style.go | 6 + server/internal/export/style_test.go | 29 ++ server/internal/export/team.go | 8 +- server/internal/export/team_all.go | 1 + server/internal/export/xlsx_test.go | 92 +++- .../internal/httpapi/adjustment_enum_test.go | 55 ++ server/internal/httpapi/athlete_batch_test.go | 93 ++++ .../httpapi/backup_restore_safety_test.go | 5 + .../internal/httpapi/backup_restore_test.go | 180 +++++++ .../internal/httpapi/display_command_test.go | 34 ++ server/internal/httpapi/entry_heat_test.go | 60 +++ server/internal/httpapi/enums.go | 10 +- server/internal/httpapi/enums_test.go | 3 + .../internal/httpapi/export_basedata_test.go | 67 +++ .../httpapi/export_basedata_tristate_test.go | 252 +++++++++ .../export_content_disposition_test.go | 4 +- server/internal/httpapi/export_flat_test.go | 4 +- server/internal/httpapi/export_handlers.go | 94 +++- .../httpapi/export_handlers_internal_test.go | 22 + .../internal/httpapi/export_handlers_test.go | 49 ++ .../internal/httpapi/export_records_test.go | 16 +- server/internal/httpapi/handlers_config.go | 83 ++- .../internal/httpapi/handlers_config_test.go | 58 ++- server/internal/httpapi/handlers_results.go | 15 +- server/internal/httpapi/handlers_revisions.go | 4 +- server/internal/httpapi/handlers_users.go | 10 +- .../internal/httpapi/handlers_users_test.go | 72 +++ server/internal/httpapi/log_files_test.go | 2 +- server/internal/httpapi/p2_fixes_test.go | 343 +++++++++++++ .../httpapi/ratelimit_intercept_log_test.go | 109 ++++ server/internal/httpapi/repo.go | 68 ++- server/internal/httpapi/repo_events.go | 44 +- .../httpapi/repo_events_internal_test.go | 66 +++ server/internal/httpapi/repo_events_test.go | 86 ++++ server/internal/httpapi/repo_import.go | 230 ++++++++- server/internal/httpapi/repo_import_test.go | 405 +++++++++++++++ server/internal/httpapi/repo_test.go | 262 ++++++++++ .../request_body_limit_internal_test.go | 74 +++ server/internal/httpapi/response.go | 13 +- server/internal/httpapi/scoring.go | 105 +++- server/internal/httpapi/scoring_test.go | 155 ++++++ .../internal/httpapi/session_revoke_test.go | 210 ++++++++ server/internal/middleware/middleware.go | 44 +- .../internal/middleware/proxy_origin_test.go | 121 +++++ server/internal/middleware/recover_test.go | 145 ++++++ server/internal/realtime/changes.go | 27 + server/internal/realtime/client.go | 121 ++++- server/internal/realtime/hub.go | 92 +++- server/internal/realtime/hub_test.go | 280 +++++++++- server/internal/realtime/instances.go | 37 +- server/internal/realtime/protocol.go | 7 + server/internal/realtime/snapshot.go | 2 +- server/internal/realtime/snapshot_test.go | 4 +- server/internal/rules/medals_test.go | 2 +- server/internal/rules/types.go | 2 +- server/internal/store/migrate.go | 58 ++- server/internal/store/store.go | 4 +- server/internal/store/store_test.go | 48 ++ server/main.go | 43 ++ server/main_test.go | 100 ++++ .../event/0008_result_revisions.sql | 4 +- .../migrations/event/migration_legacy_test.go | 291 +++++++++++ web/scripts/check-declaration-order.d.mts | 23 + web/src/admin/components/ConfirmDialog.vue | 20 +- .../admin/components/baseData/EntryPicker.vue | 67 ++- .../components/baseData/ImportSection.vue | 10 +- .../components/baseData/ProfilesSection.vue | 9 +- .../admin/components/baseData/baseDataApi.ts | 4 + .../admin/components/baseData/importLimits.ts | 18 + .../admin/components/baseData/mockBaseData.ts | 11 +- .../admin/components/baseData/useBaseData.ts | 7 - .../components/history/revisionsSource.ts | 68 ++- .../admin/components/system/BackupPanel.vue | 10 +- .../admin/components/system/UsersPanel.vue | 4 +- web/src/admin/components/system/systemApi.ts | 28 +- web/src/admin/layouts/AdminLayout.vue | 15 +- web/src/admin/pages/EntryPage.vue | 24 +- web/src/admin/pages/ExportPage.vue | 2 +- web/src/admin/pages/HistoryPage.vue | 9 +- web/src/admin/pages/StandingsPage.vue | 6 +- web/src/admin/pages/entryBatch.ts | 7 +- web/src/admin/stores/auth.ts | 7 +- .../admin/tests/athlete-soft-delete.test.ts | 40 ++ .../admin/tests/basedata-import-api.test.ts | 40 +- .../tests/confirm-dialog-pending.test.ts | 39 ++ web/src/admin/tests/entry-batch-api.test.ts | 22 + .../tests/entry-picker-conflicts.test.ts | 117 +++++ .../tests/entry-sample-data-guard.test.ts | 63 +++ web/src/admin/tests/guest-role.test.ts | 54 ++ web/src/admin/tests/history-guest.test.ts | 116 +++++ web/src/admin/tests/import-limits.test.ts | 40 ++ web/src/admin/tests/restore-notice.test.ts | 82 +++ .../admin/tests/settlement-checkbox.test.ts | 32 ++ .../admin/tests/standings-export-api.test.ts | 6 +- web/src/display/components/TickerBar.vue | 7 +- .../components/pages/TeamMedalsPage.vue | 2 +- web/src/display/guards.ts | 14 +- web/src/display/mock.ts | 4 +- web/src/display/template.ts | 8 + .../tests/rest-fallback-guards.test.ts | 49 ++ web/src/display/tests/rotate-seconds.test.ts | 55 ++ web/src/display/tests/template-errors.test.ts | 57 ++ web/src/display/types.ts | 4 +- web/src/display/useScoreboard.ts | 39 +- web/src/shared/api.ts | 97 ++-- web/src/shared/basedata.ts | 15 +- web/src/shared/tests/request-id.test.ts | 131 +++++ web/src/shared/types.ts | 5 - 147 files changed, 8141 insertions(+), 423 deletions(-) create mode 100644 console/main/service.test.js create mode 100644 server/internal/auth/session_cookie_test.go create mode 100644 server/internal/auth/session_integrity_test.go create mode 100644 server/internal/domain/scoring_boundary_test.go create mode 100644 server/internal/httpapi/adjustment_enum_test.go create mode 100644 server/internal/httpapi/export_basedata_tristate_test.go create mode 100644 server/internal/httpapi/p2_fixes_test.go create mode 100644 server/internal/httpapi/ratelimit_intercept_log_test.go create mode 100644 server/internal/httpapi/repo_events_internal_test.go create mode 100644 server/internal/httpapi/repo_events_test.go create mode 100644 server/internal/httpapi/repo_import_test.go create mode 100644 server/internal/httpapi/request_body_limit_internal_test.go create mode 100644 server/internal/httpapi/session_revoke_test.go create mode 100644 server/internal/middleware/proxy_origin_test.go create mode 100644 server/internal/middleware/recover_test.go create mode 100644 server/migrations/event/migration_legacy_test.go create mode 100644 web/scripts/check-declaration-order.d.mts create mode 100644 web/src/admin/components/baseData/importLimits.ts create mode 100644 web/src/admin/tests/athlete-soft-delete.test.ts create mode 100644 web/src/admin/tests/confirm-dialog-pending.test.ts create mode 100644 web/src/admin/tests/entry-picker-conflicts.test.ts create mode 100644 web/src/admin/tests/entry-sample-data-guard.test.ts create mode 100644 web/src/admin/tests/guest-role.test.ts create mode 100644 web/src/admin/tests/history-guest.test.ts create mode 100644 web/src/admin/tests/import-limits.test.ts create mode 100644 web/src/admin/tests/restore-notice.test.ts create mode 100644 web/src/admin/tests/settlement-checkbox.test.ts create mode 100644 web/src/display/tests/rest-fallback-guards.test.ts create mode 100644 web/src/display/tests/rotate-seconds.test.ts create mode 100644 web/src/shared/tests/request-id.test.ts diff --git a/VERSION b/VERSION index 3380654..c596d97 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.1.9 +dev_0.2.0 diff --git a/console/README.md b/console/README.md index 28577ac..3aec183 100644 --- a/console/README.md +++ b/console/README.md @@ -25,6 +25,7 @@ console/ │ ├── userpaths.js # 配置/规则目录解析 + 首次复制迁移(config.json / config.yaml / rules) │ ├── userpaths.test.js # 配置/规则路径的单元测试 │ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启 +│ ├── service.test.js # 守护逻辑的单元测试(注入假 spawn/时钟/HTTP) │ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances │ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd │ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载 @@ -87,6 +88,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 - Console 把最终目录通过服务端的 `-data <绝对路径>` 传下去,`passwd` 子命令带同一个参数,保证改的是同一个库里的账号; - 迁移只在**打包态**做一次:**目标目录为空(或不存在)且 `<安装目录>\data` 有数据**时,用 `fs.renameSync` 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留; +- **「没有旧数据」与「读不到旧目录」分开处理**:旧目录不存在/存在但空才是 `no-legacy`(不写日志);旧目录读不出来(EACCES、ENOTDIR 等)返回 `legacy-unreadable` 并写一条「读不到旧数据目录」警告,绝不当成「没有旧数据」静默跳过(回归测试见 `main/datadir.test.js`); - 两边都有数据时**不做任何动作**,只写一条醒目日志提示人工确认,两份都不会被自动删除; - 迁移结果与最终数据目录都写进 `logs/console.log`(搜「数据目录」「已把旧数据从」); - 数据目录若仍在安装目录内,启动时会额外打一条警告。 @@ -126,7 +128,8 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=&instance=-`,页序按 `display.pages` 轮转 | | 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 | | 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 | -| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 | +| 服务端崩溃 | 按 `restartDelayMs` **线性**退避(第 N 次等 N × `restartDelayMs`,最多 5 倍)自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 | +| 服务端按设计退出 | 切换赛事 / 一键恢复时服务端**先回执 200 再自己优雅退出**(退出码 0)。这种退出**不当崩溃**:状态是「重启中」(黄灯,不是红灯)、崩溃计数不 +1、同样按 `restartDelayMs` 自动拉起。判定见 `service.js` 的 `isPlannedExit`:退出码 0 **且**落在预期退出窗口内(最后一次健康检查通过后最多 15 秒,HealthInterval 更大时按两个探测周期放宽)——刚起来就退 0 或卡死很久才退 0 都仍按崩溃处理 | | 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 | | 配置与规则 | 同样在 `%LOCALAPPDATA%\ScoreboardConsole\`(`console\config.json`、`config.yaml`、`rules\`),打包态首次从安装目录复制、之后只用用户目录那份;拉起服务端时 `-config` 传绝对路径(见「配置与规则目录」一节) | | 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 | @@ -137,7 +140,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | | 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` | | 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 | -| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录 | +| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows);文件固定落在部署包根目录(打包态 = 安装目录),不随配置搬到用户数据目录;管理窗口按同一个 `log.maxLines` 截断(超上限从头部丢,长跑不变卡) | | 服务管理窗口的路径显示 | 「服务端程序」「配置文件」两行分别是解析出的 `scoreboard.exe` 与 Console 自己那份 `config.json` 的绝对路径(打包态后者在用户数据目录里,见上一节) | ## 配置项(`config.json`;打包态生效的是用户数据目录里那份) @@ -150,7 +153,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) | | `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 | | `server.healthIntervalMs` | `5000` | 健康检查间隔 | -| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 | +| `server.restartDelayMs` | `3000` | 重启基础退避:**第 N 次等 N × 本值**(×1..×5,线性,不是翻倍) | | `server.maxRestarts` | `0` | 0 = 无限重启 | | `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 | | `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 | @@ -165,7 +168,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖 | `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 | | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | | `startup.openAtLogin` | `false` | 默认注册但不启用(任务管理器可见、已禁用);在服务管理窗口「开机自启」开关里控制(开发态不注册) | -| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | +| `admin.width` / `admin.height` | `1280` / `860` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | | `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件;`log.file` 相对部署包根目录解析(不随配置搬到用户数据目录) | 配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 @@ -182,7 +185,7 @@ pnpm dist # electron-builder --win --x64 → ../dist/console/ pnpm pack # 只出目录,不生成安装包,便于现场调试 ``` -- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.1-x64.*`)。 +- 产物:NSIS 安装包 + zip,文件名形如 `Scoreboard Console-<版本>-x64.*`。`<版本>` 不是手写的:`deploy/prepare-extra.mjs` 在打包前读仓库根 `VERSION`(当前 `dev_0.1.8`)去掉 `dev_` 前缀写进 `console/package.json` 的 `version`,`artifactName` 用 `${productName}-${version}-${arch}.${ext}` 展开 —— 也就是说**这里不要手改版本号**,改仓库根的 `VERSION`。 - `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。 - 部署包根目录的说明书是可选的:`deploy/prepare-extra.mjs` 在打包前按一张清单暂存到 `console/extra/`(`extraFiles` 会把该目录内容平铺到安装目录根):`docs/使用手册.md` → `使用说明.txt`,`docs/报名名单导入规范.md` → `报名名单导入规范.txt`(各自优先取 `docs/` 下的同名 `.txt`)。缺哪个就在构建日志里注明并跳过。 - 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 diff --git a/console/main/datadir.js b/console/main/datadir.js index 0441b1b..5c7c84d 100644 --- a/console/main/datadir.js +++ b/console/main/datadir.js @@ -80,6 +80,16 @@ function migrateLegacyData({ targetDir, legacyDir, log }) { return { migrated: false, reason: 'target-unreadable' } } + // 旧目录读不出来(EACCES / EPERM / ENOTDIR 等)与「没有旧数据」是两回事: + // 前者可能是一份没搬成功的真实数据,不能静默按「没数据」处理,必须留一条日志让人去人工确认。 + if (legacy === 'error') { + notify( + `警告:读不到旧数据目录 ${legacyDir}(权限不足或路径不可用),本次不做迁移。` + + '请人工确认该目录里是否有需要保留的数据' + ) + return { migrated: false, reason: 'legacy-unreadable' } + } + if (target === 'has-data') { if (legacy === 'has-data') { notify( diff --git a/console/main/datadir.test.js b/console/main/datadir.test.js index 660014e..9ce058d 100644 --- a/console/main/datadir.test.js +++ b/console/main/datadir.test.js @@ -189,3 +189,38 @@ test('目标路径读不出来时按「可能有数据」处理,不做迁移', assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'db') assert.match(lines.join('\n'), /跳过旧数据迁移/) }) + +test('旧路径读不出来时不当成「没有旧数据」,要记一条日志', (t) => { + const root = makeRoot(t) + const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data') + fs.mkdirSync(targetDir, { recursive: true }) + // 旧目录的上级是个文件:readdir 报 ENOTDIR,等同于现场遇到 EACCES 这类「读不到」 + const blocker = path.join(root, 'blocker') + writeFile(blocker, 'not a dir') + + const lines = [] + const result = migrateLegacyData({ + targetDir, + legacyDir: path.join(blocker, 'data'), + log: (line) => lines.push(line) + }) + + assert.equal(result.migrated, false) + assert.equal(result.reason, 'legacy-unreadable') + assert.match(lines.join('\n'), /读不到旧数据目录/) +}) + +test('确实没有旧数据时只返回 no-legacy,不写警告日志', (t) => { + const root = makeRoot(t) + const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data') + fs.mkdirSync(targetDir, { recursive: true }) + const legacyDir = path.join(root, 'install', 'data') + fs.mkdirSync(legacyDir, { recursive: true }) + + const lines = [] + const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) }) + + assert.equal(result.migrated, false) + assert.equal(result.reason, 'no-legacy') + assert.deepEqual(lines, []) +}) diff --git a/console/main/index.js b/console/main/index.js index 29dd760..ec1b672 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -90,7 +90,10 @@ function main() { powerSaveBlocked: powerBlockerId !== null ? powerSaveBlocker.isStarted(powerBlockerId) : false, openAtLogin: openAtLoginEffective(), openAtLoginSupported: app.isPackaged && (process.platform === 'win32' || process.platform === 'darwin'), - appVersion: app.getVersion() + appVersion: app.getVersion(), + // 渲染层按同一个上限裁剪日志节点(见 renderer/app.js appendLog): + // 主进程只留最后 maxLines 行,页面若只增不减,长赛事跑一天会把窗口拖卡 + logMaxLines: cfg ? cfg.values.log.maxLines : null } } @@ -332,7 +335,13 @@ function main() { function buildTrayMenu() { const status = statusSnapshot() - const busy = status.state === 'starting' || status.state === 'running' || status.state === 'stopping' + // 「重启中」也算忙:此时子进程已经退出、自动拉起还排在定时器上, + // 允许点「启动服务」会与那次自动拉起抢同一个端口 + const busy = + status.state === 'starting' || + status.state === 'running' || + status.state === 'restarting' || + status.state === 'stopping' return Menu.buildFromTemplate([ { label: `服务端:${status.stateLabel}${status.healthy ? ' · 就绪' : ''}`, @@ -534,10 +543,6 @@ function main() { }) ipcMain.handle('display:open', (event, displayId, options) => openDisplayWindow(displayId, options)) ipcMain.handle('display:close', (event, displayId) => closeDisplayWindow(displayId)) - ipcMain.handle('app:open-admin-window', () => { - showAdmin() - return true - }) ipcMain.handle('app:open-server-admin', () => { showServerAdmin() return true diff --git a/console/main/service.js b/console/main/service.js index 9413597..af7755a 100644 --- a/console/main/service.js +++ b/console/main/service.js @@ -14,6 +14,9 @@ const STATE_LABEL = { starting: '启动中', running: '运行中', stopping: '停止中', + // 服务端自己按设计退出(切换赛事 / 一键恢复)后的等待期:与「异常退出」必须分开显示, + // 现场看到红灯会以为服务挂了,实际是正常收尾等 Console 拉回来。 + restarting: '重启中', crashed: '异常退出' } @@ -29,6 +32,18 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000 const FALLBACK_FORCE_KILL_MS = 1500 // 连续健康这么久才认为崩溃计数可以清零 const HEALTHY_RESET_MS = 60000 +// 「预期退出窗口」的下限:最后一次确认服务端还活着之后的这段时间内退出,才可能是按设计退出。 +// 健康探测默认 5 秒一次,窗口至少覆盖两三个探测周期,避免刚好卡在探测空档时漏判。 +const EXPECTED_EXIT_WINDOW_MS = 15000 + +// 副作用边界集中在这里注入:进程、时钟、HTTP。生产路径直接用 Node 的实现, +// 单元测试传假实现,才能在毫秒内跑完退避、超时、优雅关闭这些真实要等几秒的分支。 +const defaultTimers = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (id) => clearTimeout(id), + setInterval: (fn, ms) => setInterval(fn, ms), + clearInterval: (id) => clearInterval(id) +} // 服务端可能监听 0.0.0.0/::,本机访问(健康检查、关闭握手、大屏、管理面板)统一走回环地址 function healthHost(server) { @@ -39,7 +54,7 @@ function healthHost(server) { // 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。 // 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。 class ServiceSupervisor extends EventEmitter { - constructor({ values, exePath, log, dataDir, configPath }) { + constructor({ values, exePath, log, dataDir, configPath, spawn: spawnFn, http: httpClient, timers, now }) { super() this.values = values this.server = values.server @@ -50,6 +65,12 @@ class ServiceSupervisor extends EventEmitter { // 绝对路径,非空时覆盖 args 里的 -config;为空表示沿用 args 原值(开发态) this.configPath = configPath || null + // 可注入的副作用边界,默认就是 Node 的实现 + this.spawn = spawnFn || spawn + this.http = httpClient || http + this.timers = { ...defaultTimers, ...(timers || {}) } + this.now = now || (() => Date.now()) + this.child = null this.state = 'stopped' this.healthy = false @@ -60,6 +81,9 @@ class ServiceSupervisor extends EventEmitter { this.stopRequested = false this.healthySince = null + this.disposed = false + // 最近一次确认服务端还在正常服务(健康检查通过)的时刻,用于「预期退出窗口」判定 + this.lastAliveAt = null this.healthTimer = null this.restartTimer = null this.forceKillTimer = null @@ -128,9 +152,12 @@ class ServiceSupervisor extends EventEmitter { this.lastError = null this.healthySince = null + // 新进程重新起算「还活着」的时刻:否则上一个进程的健康记录会让 + // 「刚起来就退出 0」被误判成按设计退出 + this.lastAliveAt = null this.gracefulAccepted = false this.forcedKill = false - this.startedAt = new Date().toISOString() + this.startedAt = new Date(this.now()).toISOString() this.setState('starting') this.log.push( 'console', @@ -143,7 +170,7 @@ class ServiceSupervisor extends EventEmitter { let child try { - child = spawn(this.exePath, spawnArgs, { + child = this.spawn(this.exePath, spawnArgs, { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], @@ -183,7 +210,7 @@ class ServiceSupervisor extends EventEmitter { // 必须在这里清掉 child/pid 并调度重启,否则 start() 因 child 非空直接返回、 // stop() 永远等不到 exit,守护器彻底卡死。 handleSpawnError(child, err) { - if (this.child !== child) return + if (this.disposed || this.child !== child) return this.stopHealthCheck() this.child = null this.pid = null @@ -194,10 +221,11 @@ class ServiceSupervisor extends EventEmitter { } handleExit(child, code, signal) { - // 迟到的 exit(error 分支已收尾或已换进程)不能动当前状态 - if (this.child !== child) return + // 迟到的 exit(error 分支已收尾、已换进程、或已经 dispose)不能动当前状态; + // 尤其是 dispose 之后:应用正在退出,这里再排一次自动重启会留下一个没人管的循环 + if (this.disposed || this.child !== child) return if (this.forceKillTimer) { - clearTimeout(this.forceKillTimer) + this.timers.clearTimeout(this.forceKillTimer) this.forceKillTimer = null } this.stopHealthCheck() @@ -216,22 +244,60 @@ class ServiceSupervisor extends EventEmitter { return } + if (this.isPlannedExit(code)) { + // 切换赛事 / 一键恢复:服务端先回执再自行优雅退出,Console 的职责是把它拉回来。 + // 这不能按崩溃算——否则现场正常切个赛事会显示红灯、崩溃计数 +1。 + this.log.push('console', `服务端按设计退出(${reason},切换赛事 / 一键恢复),准备自动拉起`) + this.setState('restarting', { lastError: null }) + this.scheduleRestart({ planned: true }) + return + } + this.setState('crashed', { lastError: `服务端意外退出(${reason})` }) this.scheduleRestart() } - scheduleRestart() { + // 「按设计退出」的判定窗口(毫秒):最后一次确认服务端还活着之后允许退出的时间。 + // 健康间隔配得大时窗口跟着放大,至少覆盖两个探测周期。 + expectedExitWindowMs() { + const interval = Number(this.server.healthIntervalMs) || 5000 + return Math.max(EXPECTED_EXIT_WINDOW_MS, interval * 2) + } + + // 服务端主动退出(退出码 0)与崩溃靠两点区分: + // 1) 退出码:Go 只有从 main 正常返回才是 0,panic 是 2、装配失败是 os.Exit(1)、被信号杀则没有退出码; + // 2) 是否在预期退出窗口内:切换赛事 / 一键恢复一定发生在服务端刚刚还在正常服务的时候 + // (管理面板先拿到 200 回执,服务端随后才 Shutdown),所以窗口从最后一次健康检查通过起算。 + // 窗口外退出 0 说明这次运行从没健康过,那不是切赛事而是异常,仍旧按崩溃处理。 + isPlannedExit(code) { + return ( + code === 0 && + this.lastAliveAt !== null && + this.now() - this.lastAliveAt <= this.expectedExitWindowMs() + ) + } + + // planned=true 表示按设计退出后的拉起:不占崩溃配额、不递增崩溃计数, + // 延迟仍用 restartDelayMs——服务端刚退出,端口与文件句柄要留一点回收时间。 + scheduleRestart({ planned = false } = {}) { const max = Number(this.server.maxRestarts) || 0 - if (max > 0 && this.restarts >= max) { + if (!planned && max > 0 && this.restarts >= max) { this.log.push('console', `已达到最大重启次数 ${max},不再自动拉起`) return } - const delay = (Number(this.server.restartDelayMs) || 3000) * Math.min(this.restarts + 1, 5) - this.restarts += 1 - this.log.push('console', `${Math.round(delay / 1000)} 秒后自动重启服务端(第 ${this.restarts} 次)`) + // 退避是线性的 ×1..×5(不是翻倍):第 N 次等 N × restartDelayMs + const attempt = planned ? 1 : this.restarts + 1 + const delay = (Number(this.server.restartDelayMs) || 3000) * Math.min(attempt, 5) + if (!planned) this.restarts += 1 + this.log.push( + 'console', + planned + ? `${Math.round(delay / 1000)} 秒后自动拉起服务端(按设计退出,不计入崩溃次数)` + : `${Math.round(delay / 1000)} 秒后自动重启服务端(第 ${this.restarts} 次)` + ) this.emitStatus() - if (this.restartTimer) clearTimeout(this.restartTimer) - this.restartTimer = setTimeout(() => { + if (this.restartTimer) this.timers.clearTimeout(this.restartTimer) + this.restartTimer = this.timers.setTimeout(() => { this.restartTimer = null if (!this.stopRequested) this.start() }, delay) @@ -240,7 +306,7 @@ class ServiceSupervisor extends EventEmitter { stop() { return new Promise((resolve) => { if (this.restartTimer) { - clearTimeout(this.restartTimer) + this.timers.clearTimeout(this.restartTimer) this.restartTimer = null } const child = this.child @@ -260,7 +326,7 @@ class ServiceSupervisor extends EventEmitter { if (settled) return settled = true if (this.stopTimer) { - clearTimeout(this.stopTimer) + this.timers.clearTimeout(this.stopTimer) this.stopTimer = null } resolve() @@ -268,7 +334,7 @@ class ServiceSupervisor extends EventEmitter { // 兜底:握手与强杀链路都可能因进程状态异常收不到 exit, // 退出流程不能永远挂住,超时后无条件放行并补一刀强杀。 - this.stopTimer = setTimeout(() => { + this.stopTimer = this.timers.setTimeout(() => { this.stopTimer = null this.log.push('console', '等待服务端退出超时,停止流程强制返回') if (this.child === child) { @@ -313,7 +379,7 @@ class ServiceSupervisor extends EventEmitter { requestShutdown() { return new Promise((resolve) => { const body = Buffer.from('{}') - const request = http.request( + const request = this.http.request( { host: this.healthHost(), port: this.server.port, @@ -344,8 +410,8 @@ class ServiceSupervisor extends EventEmitter { } armForceKill(ms) { - if (this.forceKillTimer) clearTimeout(this.forceKillTimer) - this.forceKillTimer = setTimeout(() => { + if (this.forceKillTimer) this.timers.clearTimeout(this.forceKillTimer) + this.forceKillTimer = this.timers.setTimeout(() => { this.forceKillTimer = null if (!this.child || !this.pid) return this.forcedKill = true @@ -368,20 +434,20 @@ class ServiceSupervisor extends EventEmitter { this.stopHealthCheck() const interval = Number(this.server.healthIntervalMs) || 5000 const tick = () => this.probe() - setTimeout(tick, 800) - this.healthTimer = setInterval(tick, interval) + this.timers.setTimeout(tick, 800) + this.healthTimer = this.timers.setInterval(tick, interval) } stopHealthCheck() { if (this.healthTimer) { - clearInterval(this.healthTimer) + this.timers.clearInterval(this.healthTimer) this.healthTimer = null } } probe() { if (!this.child) return - const request = http.get( + const request = this.http.get( { host: this.healthHost(), port: this.server.port, @@ -401,7 +467,9 @@ class ServiceSupervisor extends EventEmitter { setHealthy(ok) { const changed = this.healthy !== ok if (ok) { - if (!this.healthySince) this.healthySince = Date.now() + // 探测通过 = 这一刻服务端还在正常服务,是「预期退出窗口」的起算点 + this.lastAliveAt = this.now() + if (!this.healthySince) this.healthySince = this.now() } else { this.healthySince = null } @@ -415,7 +483,7 @@ class ServiceSupervisor extends EventEmitter { let reset = false if (ok && this.restarts > 0) { const resetAfter = Number(this.server.healthyResetMs) || HEALTHY_RESET_MS - if (Date.now() - this.healthySince >= resetAfter) { + if (this.now() - this.healthySince >= resetAfter) { this.restarts = 0 reset = true this.log.push('console', '服务端持续健康,崩溃计数已清零') @@ -426,9 +494,12 @@ class ServiceSupervisor extends EventEmitter { } dispose() { - if (this.restartTimer) clearTimeout(this.restartTimer) - if (this.forceKillTimer) clearTimeout(this.forceKillTimer) - if (this.stopTimer) clearTimeout(this.stopTimer) + // dispose 之后连子进程的 exit 都不再处理(见 handleExit 的守卫), + // 否则「应用已退出、子进程后死」会重新排一次重启,留下一个没人观察的守护循环 + this.disposed = true + if (this.restartTimer) this.timers.clearTimeout(this.restartTimer) + if (this.forceKillTimer) this.timers.clearTimeout(this.forceKillTimer) + if (this.stopTimer) this.timers.clearTimeout(this.stopTimer) this.stopHealthCheck() this.removeAllListeners() } diff --git a/console/main/service.test.js b/console/main/service.test.js new file mode 100644 index 0000000..e2de5b6 --- /dev/null +++ b/console/main/service.test.js @@ -0,0 +1,485 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') +const { EventEmitter } = require('node:events') + +const { ServiceSupervisor, healthHost } = require('./service') + +// service.js 的外部依赖只有三条边界:spawn / 时钟 / HTTP。构造函数支持注入后, +// 「等 3 秒退避」「优雅关闭超时强杀」这些真实要挂几秒的分支可以在毫秒内跑完, +// 而且能精确断言「第几次重启、等了多少毫秒、进程被怎么结束的」。 +// 这里不碰真实进程与网络,全部用假实现;真机链路(.exe 拉起、端口探测)只能在 Windows 上跑。 + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +// 假时钟:定时器只在 advance() 里执行,测试因此完全不受真实时间影响 +function makeClock(start = Date.parse('2026-09-11T00:00:00.000Z')) { + let now = start + let seq = 0 + const tasks = new Map() + + const schedule = (fn, ms, every) => { + const id = ++seq + tasks.set(id, { fn, at: now + Math.max(0, Number(ms) || 0), every }) + return id + } + + return { + now: () => now, + timers: { + setTimeout: (fn, ms) => schedule(fn, ms, 0), + clearTimeout: (id) => tasks.delete(id), + setInterval: (fn, ms) => schedule(fn, ms, Math.max(1, Number(ms) || 1)), + clearInterval: (id) => tasks.delete(id) + }, + scheduled: () => tasks.size, + advance(ms) { + const target = now + ms + for (;;) { + let due = null + for (const [id, task] of tasks) { + if (task.at <= target && (!due || task.at < due.task.at)) due = { id, task } + } + if (!due) break + now = due.task.at + if (due.task.every) due.task.at = now + due.task.every + else tasks.delete(due.id) + due.task.fn() + } + now = target + } + } +} + +// 假子进程:只实现 service.js 用到的那几样(stdout/stderr 流、exit/error 事件、kill) +class FakeChild extends EventEmitter { + constructor(pid) { + super() + this.pid = pid + this.stdout = new FakeStream() + this.stderr = new FakeStream() + this.kills = [] + } + + kill(signal) { + this.kills.push(signal || 'SIGTERM') + return true + } + + // code 为 null、signal 非空 = 被信号杀(Node 在 Windows 上强杀时 code 非 0) + exit(code, signal = null) { + this.emit('exit', code, signal) + } +} + +class FakeStream extends EventEmitter { + setEncoding() {} +} + +// 假 HTTP:健康探测与关闭握手分开控制,测试自己决定服务端「答不答、答什么」 +function makeHttpStub() { + const state = { + probeStatus: 200, + probeError: null, + shutdown: 'accept', + probes: [], + shutdowns: [] + } + + return { + state, + get(options, callback) { + state.probes.push(options) + const request = new EventEmitter() + request.destroy = () => {} + process.nextTick(() => { + if (state.probeError) request.emit('error', new Error(state.probeError)) + else callback({ statusCode: state.probeStatus, resume() {} }) + }) + return request + }, + request(options, callback) { + const request = new EventEmitter() + request.destroy = (err) => { + if (err) request.emit('error', err) + } + request.end = () => { + state.shutdowns.push(options) + process.nextTick(() => { + if (state.shutdown === 'accept') callback({ statusCode: 200, resume() {} }) + else if (state.shutdown === 'http500') callback({ statusCode: 500, resume() {} }) + else request.emit('error', new Error('connect ECONNREFUSED 127.0.0.1:19540')) + }) + } + return request + } + } +} + +function makeLog() { + const lines = [] + return { + lines, + push: (source, message) => lines.push(`[${source}] ${message}`), + append: (source, chunk) => lines.push(`[${source}] ${String(chunk)}`), + text: () => lines.join('\n') + } +} + +function setup(t, serverOverrides = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-service-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const exePath = path.join(root, 'scoreboard.exe') + fs.writeFileSync(exePath, 'stub') + + const clock = makeClock() + const http = makeHttpStub() + const log = makeLog() + const children = [] + const spawnCalls = [] + + const service = new ServiceSupervisor({ + values: { + server: { + exe: 'scoreboard.exe', + args: ['-config', 'config.yaml'], + host: '127.0.0.1', + port: 19540, + healthIntervalMs: 5000, + restartDelayMs: 3000, + maxRestarts: 0, + shutdownPath: '/api/shutdown', + shutdownTimeoutMs: 5000, + healthyResetMs: 60000, + ...serverOverrides + } + }, + exePath, + log, + spawn: (file, args, options) => { + spawnCalls.push({ file, args, options }) + const child = new FakeChild(1000 + children.length) + children.push(child) + return child + }, + http, + timers: clock.timers, + now: clock.now + }) + + return { service, clock, http, log, children, spawnCalls } +} + +// 让健康探测通过一次:等 800ms 的首次探测 + 一次 IPC 往返 +async function markAlive(clock) { + clock.advance(800) + await flush() +} + +test('healthHost:监听 0.0.0.0/:: 时本机探测改走回环地址', () => { + assert.equal(healthHost({ host: '0.0.0.0' }), '127.0.0.1') + assert.equal(healthHost({ host: '::' }), '127.0.0.1') + assert.equal(healthHost({ host: '192.168.1.10' }), '192.168.1.10') + assert.equal(healthHost({}), '127.0.0.1') +}) + +test('崩溃:退出码非 0 → 红灯 + 崩溃计数 +1,退避是线性的 ×1..×5', async (t) => { + const { service, clock, children, spawnCalls, log } = setup(t) + + service.start() + assert.equal(service.state, 'running') + assert.equal(spawnCalls.length, 1) + // 参数里带上了 -config 的绝对路径与目录,spawn 的工作目录是 exe 所在目录 + assert.equal(spawnCalls[0].options.cwd, path.dirname(spawnCalls[0].file)) + assert.ok(spawnCalls[0].options.env.SCOREBOARD_SHUTDOWN_TOKEN) + + children[0].exit(1) + assert.equal(service.state, 'crashed') + assert.equal(service.restarts, 1) + assert.match(service.lastError, /退出码 1/) + assert.match(log.text(), /3 秒后自动重启服务端(第 1 次)/) + + // 第 1 次退避 = 3000ms(×1),到点自动拉起 + clock.advance(2999) + assert.equal(spawnCalls.length, 1) + clock.advance(1) + assert.equal(spawnCalls.length, 2) + + // 第 2 次退避 = 6000ms(×2):线性递增,不是翻倍 + children[1].exit(1) + assert.equal(service.restarts, 2) + clock.advance(5999) + assert.equal(spawnCalls.length, 2) + clock.advance(1) + assert.equal(spawnCalls.length, 3) + assert.match(log.text(), /6 秒后自动重启服务端(第 2 次)/) + + // 连崩到第 6 次,退避封顶在 5 倍(×5),不再继续放大 + for (let i = 0; i < 4; i += 1) { + children[children.length - 1].exit(1) + clock.advance(15000) + } + assert.equal(service.restarts, 6) + children[children.length - 1].exit(1) + assert.match(log.text(), /15 秒后自动重启服务端(第 7 次)/) +}) + +test('崩溃:达到 maxRestarts 后不再自动拉起', async (t) => { + const { service, clock, children, spawnCalls, log } = setup(t, { maxRestarts: 2 }) + + service.start() + children[0].exit(1) + clock.advance(3000) + children[1].exit(1) + clock.advance(6000) + // 配额内的两次自动重启已经用掉 + assert.equal(service.restarts, 2) + assert.equal(spawnCalls.length, 3) + + children[2].exit(1) + assert.equal(service.state, 'crashed') + assert.match(log.text(), /已达到最大重启次数 2,不再自动拉起/) + clock.advance(60000) + assert.equal(spawnCalls.length, 3) +}) + +test('崩溃:spawn 只发 error 不发 exit 时也要调度重启(否则守护器卡死)', async (t) => { + const { service, clock, children, spawnCalls } = setup(t) + + service.start() + children[0].emit('error', new Error('spawn EACCES')) + + assert.equal(service.state, 'crashed') + assert.equal(service.child, null) + assert.match(service.lastError, /EACCES/) + + clock.advance(3000) + assert.equal(spawnCalls.length, 2) +}) + +test('优雅停止:握手受理 → 等它自己退出,状态是已停止且不重启', async (t) => { + const { service, clock, children, http, spawnCalls, log } = setup(t) + + service.start() + const stopping = service.stop() + await flush() + + assert.equal(service.state, 'stopping') + assert.equal(http.state.shutdowns.length, 1) + // 握手带上了启动时下发的 token 与关闭路径 + assert.equal(http.state.shutdowns[0].path, '/api/shutdown') + assert.match(http.state.shutdowns[0].headers['X-Shutdown-Token'], /^[0-9a-f]{64}$/) + assert.equal(http.state.shutdowns[0].method, 'POST') + assert.match(log.text(), /已接受优雅关闭请求/) + // 受理后不会先去发信号,等服务端自己退出 + assert.deepEqual(children[0].kills, []) + + children[0].exit(0) + await stopping + + assert.equal(service.state, 'stopped') + assert.equal(service.lastError, null) + assert.match(log.text(), /服务端已优雅退出(退出码 0)/) + clock.advance(60000) + assert.equal(spawnCalls.length, 1) +}) + +test('优雅停止:握手不可用 → 退回强杀,退出后仍算已停止而不是崩溃', async (t) => { + const { service, clock, children, http, log } = setup(t) + http.state.shutdown = 'unreachable' + + service.start() + const stopping = service.stop() + await flush() + + assert.match(log.text(), /优雅关闭接口不可用,退回强制结束/) + assert.deepEqual(children[0].kills, ['SIGTERM']) + + // 强杀兜底到期前进程自己退了 → 记「已停止」(不是「已被强制结束」) + children[0].exit(1, 'SIGTERM') + await stopping + + assert.equal(service.state, 'stopped') + assert.match(log.text(), /服务端已停止(信号 SIGTERM)/) +}) + +test('优雅停止:服务端拖着不退 → 到点强杀并放行,流程不会永远挂住', async (t) => { + const { service, clock, children, log } = setup(t) + let settled = false + + service.start() + const stopping = service.stop().then(() => { + settled = true + }) + await flush() + + // 受理后等 shutdownTimeoutMs(5000) 再补一刀强杀,不能先发信号 + clock.advance(4999) + assert.deepEqual(children[0].kills, []) + clock.advance(1) + assert.deepEqual(children[0].kills, ['SIGKILL']) + assert.match(log.text(), /服务端未在 5 秒内退出,强制结束/) + + children[0].exit(null, 'SIGKILL') + await stopping + assert.equal(settled, true) + assert.equal(service.state, 'stopped') + assert.match(log.text(), /服务端已被强制结束(信号 SIGKILL)/) +}) + +test('停止:没有子进程时直接回到已停止,并取消已排期的重启', async (t) => { + const { service, clock, children, spawnCalls } = setup(t) + + service.start() + children[0].exit(1) + assert.equal(service.state, 'crashed') + + // 崩溃等待期内点「停止」:不能等到点又把服务端拉起来 + await service.stop() + assert.equal(service.state, 'stopped') + clock.advance(60000) + assert.equal(spawnCalls.length, 1) +}) + +test('按设计退出:健康过的服务端以退出码 0 退出 → 重启中(不是红灯),不计崩溃', async (t) => { + const { service, clock, children, spawnCalls, log } = setup(t) + + service.start() + await markAlive(clock) + assert.equal(service.healthy, true) + + // 切换赛事 / 一键恢复:服务端回执 200 后自行优雅退出 + children[0].exit(0) + + assert.equal(service.state, 'restarting') + assert.equal(service.status.stateLabel, '重启中') + assert.equal(service.restarts, 0) + assert.equal(service.lastError, null) + assert.match(log.text(), /服务端按设计退出(退出码 0,切换赛事 \/ 一键恢复)/) + assert.match(log.text(), /不计入崩溃次数/) + + // 仍然要自动拉起(延迟与首次崩溃退避一致,留端口回收时间) + clock.advance(3000) + assert.equal(spawnCalls.length, 2) +}) + +test('按设计退出:全新进程刚起来就退出码 0,不算按设计退出(当崩溃处理)', async (t) => { + const { service, children, log } = setup(t) + + service.start() + children[0].exit(0) + + // 没健康过就没有「预期退出窗口」:这不是切赛事,按崩溃退避重试 + assert.equal(service.state, 'crashed') + assert.equal(service.restarts, 1) + assert.match(service.lastError, /服务端意外退出(退出码 0)/) + assert.doesNotMatch(log.text(), /按设计退出/) +}) + +test('按设计退出:健康记录超出预期退出窗口后退出码 0 → 仍按崩溃处理', async (t) => { + const { service, clock, http, children } = setup(t) + + service.start() + await markAlive(clock) + assert.equal(service.healthy, true) + + // 服务端卡死:探测开始失败,最后一次确认「还活着」的时间停留在窗口之前 + http.state.probeError = 'connect ECONNREFUSED 127.0.0.1:19540' + clock.advance(20000) + await flush() + assert.equal(service.healthy, false) + + children[0].exit(0) + assert.equal(service.state, 'crashed') + assert.equal(service.restarts, 1) +}) + +test('健康恢复:持续健康超过 healthyResetMs 后崩溃计数清零', async (t) => { + const { service, clock, children, log } = setup(t) + + service.start() + children[0].exit(1) + clock.advance(3000) + assert.equal(service.restarts, 1) + + await markAlive(clock) + clock.advance(60000) + await flush() + + assert.equal(service.restarts, 0) + assert.match(log.text(), /服务端持续健康,崩溃计数已清零/) +}) + +test('健康检查失败:探测报错时状态为不健康但不改变进程状态', async (t) => { + const { service, clock, http } = setup(t) + http.state.probeError = 'connect ECONNREFUSED 127.0.0.1:19540' + + service.start() + await markAlive(clock) + + assert.equal(service.healthy, false) + assert.equal(service.state, 'running') + assert.match(service.status.url, /^http:\/\/127\.0\.0\.1:19540$/) +}) + +test('手动重启:先优雅停再拉起,崩溃计数清零', async (t) => { + const { service, clock, children, spawnCalls } = setup(t) + + service.start() + children[0].exit(1) + clock.advance(3000) + assert.equal(service.restarts, 1) + + const restarting = service.restart() + await flush() + children[children.length - 1].exit(0) + await restarting + + assert.equal(service.restarts, 0) + assert.equal(service.state, 'running') + assert.equal(spawnCalls.length, 3) +}) + +test('迟到的 exit:换过进程之后旧进程的 exit 不能改当前状态', async (t) => { + const { service, clock, children } = setup(t) + + service.start() + children[0].exit(1) + clock.advance(3000) + const current = children[1] + + // 第二次崩溃前,第一个进程迟到的 exit 事件又冒出来一次 + service.handleExit(children[0], 1, null) + assert.equal(service.state, 'running') + assert.equal(service.child, current) +}) + +test('dispose:清掉所有排期,之后不再拉起新进程', async (t) => { + const { service, clock, children, spawnCalls } = setup(t) + + service.start() + children[0].exit(1) + assert.equal(service.state, 'crashed') + + service.dispose() + clock.advance(60000) + assert.equal(spawnCalls.length, 1) +}) + +test('dispose:子进程随后才退出也不能再排一次重启', async (t) => { + const { service, clock, children, spawnCalls } = setup(t) + + service.start() + const child = children[0] + service.dispose() + + // 应用已经在退出,这个迟到的 exit 不该再被当成崩溃去安排守护 + child.exit(1) + assert.equal(service.state, 'running') + clock.advance(60000) + assert.equal(spawnCalls.length, 1) +}) diff --git a/console/package.json b/console/package.json index cd0c517..d2ef44a 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.1.9", + "version": "0.2.0", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/console/renderer/app.js b/console/renderer/app.js index fee23b1..6788ed9 100644 --- a/console/renderer/app.js +++ b/console/renderer/app.js @@ -41,18 +41,33 @@ const DOT_CLASS = { running: 'ok', starting: 'warn', stopping: 'warn', + // 服务端按设计退出(切换赛事 / 一键恢复)后的等待期:黄灯而不是红灯 + restarting: 'warn', crashed: 'err', stopped: '' } +// 没有 lastError 时按状态给一句人话,别让「重启中」落到通用的「等待健康检查」上 +const DETAIL_FALLBACK = { + stopped: '服务未运行', + restarting: '服务端按设计退出,正在自动拉起' +} + function renderStatus(status) { el.version.textContent = status.appVersion ? `v${status.appVersion}` : '' + // 日志上限跟着主进程配置走(status.logMaxLines):先更新上限再裁一次, + // 否则开机先灌进来的那批日志会一直超着 + if (Number.isFinite(status.logMaxLines) && status.logMaxLines > 0 && status.logMaxLines !== logMaxLines) { + logMaxLines = status.logMaxLines + trimLog() + } + el.stateDot.className = `dot ${status.healthy ? 'ok' : DOT_CLASS[status.state] || ''}` el.stateLabel.textContent = status.stateLabel || status.state el.stateDetail.textContent = status.healthy ? '健康检查通过' - : status.lastError || (status.state === 'stopped' ? '服务未运行' : '等待健康检查') + : status.lastError || DETAIL_FALLBACK[status.state] || '等待健康检查' el.url.textContent = status.url || '—' el.pid.textContent = status.pid || '—' @@ -69,7 +84,7 @@ function renderStatus(status) { el.configWarning.hidden = true } - const busy = ['starting', 'running', 'stopping'].includes(status.state) + const busy = ['starting', 'running', 'restarting', 'stopping'].includes(status.state) el.btnStart.disabled = busy el.btnStop.disabled = !busy el.btnRestart.disabled = !busy @@ -198,16 +213,28 @@ function renderInstances(payload) { } } +// 日志上限(行):与主进程 LogBus 的 log.maxLines 一致,由状态快照下发; +// 主进程只留最后 N 行,但每一行都会实时 IPC 过来,页面若只增不减, +// 长赛事跑一天会堆出几十万个文本节点,窗口越用越卡。 +let logMaxLines = 2000 + +// 超上限就从头部丢:一行一个文本节点,单次追加是 O(1),不做整块 textContent 重排 +function trimLog() { + while (el.log.childNodes.length > logMaxLines) el.log.removeChild(el.log.firstChild) +} + function appendLog(line) { const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8 - el.log.textContent += `${line}\n` + el.log.append(document.createTextNode(`${line}\n`)) + trimLog() if (el.autoscroll.checked && atBottom) { el.log.scrollTop = el.log.scrollHeight } } function setLog(lines) { - el.log.textContent = lines.length ? `${lines.join('\n')}\n` : '' + el.log.textContent = '' + for (const line of lines.slice(-logMaxLines)) el.log.append(document.createTextNode(`${line}\n`)) el.log.scrollTop = el.log.scrollHeight } diff --git a/deploy/backup.bat b/deploy/backup.bat index 953eba8..9523960 100644 --- a/deploy/backup.bat +++ b/deploy/backup.bat @@ -88,11 +88,18 @@ set /a FAIL=0 REM The server backs up one event DB at a time (system.db is always included), REM so loop over the project directories. A typical deployment has one project. +REM Project names come from `dir /b /ad` (names only, no paths) rather than from +REM `for /d %%D in ("%DATA%\event_projs\*")`: cmd runs that FOR body once with the +REM unexpanded pattern when the wildcard matches nothing, which set FOUND, handed +REM "-project *" to the server (illegal name -> exit 1) and made the fallback below +REM dead code. `dir /b` prints nothing for an empty or missing folder, so the +REM fallback is reachable by construction. "eol=|" only disables the default ";" +REM end-of-line comment so a project name starting with ; is not skipped. set "FOUND=" -for /d %%D in ("%DATA%\event_projs\*") do ( +for /f "delims= eol=|" %%D in ('dir /b /ad "%DATA%\event_projs" 2^>nul') do ( set "FOUND=1" - echo backup event %%~nxD - "%EXE%" backup -config "%CONFIG%" -project "%%~nxD" -data "%DATA%" -reason "%REASON%" + echo backup event %%D + "%EXE%" backup -config "%CONFIG%" -project "%%D" -data "%DATA%" -reason "%REASON%" if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1) ) if not defined FOUND ( @@ -103,12 +110,12 @@ if not defined FOUND ( 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. +REM of a single run sort together. Same iteration as above (see the note there). 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" +for /f "delims= eol=|" %%D in ('dir /b /ad "%DATA%\event_projs" 2^>nul') do call :archive_uploads "%%D" "%DATA%\event_projs\%%D" echo. echo === backup done: ok=!OK! fail=!FAIL! assets=!ASSETS! assets_fail=!ASSETS_FAIL! === @@ -138,7 +145,15 @@ if not defined HASFILES ( 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" +REM The paths go through the environment instead of being spliced into the +REM PowerShell source: project names are user-supplied (the server only rejects +REM / \ : * ? " < > | and control chars, so a single quote is allowed), and a +REM single quote would close the -Path string, make PowerShell fail to parse the +REM command and leave the whole script exiting 1. Environment values need no +REM quoting at all inside the command string. +set "SCOREBOARD_UPLOADS=%~2\uploads" +set "SCOREBOARD_ZIP=%DATA%\backups\%~1-uploads-%STAMP%.zip" +powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; Compress-Archive -Path $env:SCOREBOARD_UPLOADS -DestinationPath $env:SCOREBOARD_ZIP -Force" if errorlevel 1 ( echo [ERROR] archiving %~1\uploads failed set /a ASSETS_FAIL+=1 diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index e4e1140..6e3c88b 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -43,9 +43,9 @@ —— 服务端不会自动加载规则包,必须导入一次 - [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分 - [ ] **按赛事规则逐项核对过「奖牌结算开关」与「奖牌通道」**:到「基础数据 → 大项 / 小项」逐个确认要发牌的小项都勾着 **「该项启用奖牌结算」**(不该发牌的项目取消勾选);再到「基础数据 → 计分通道」逐项核对每条通道的 **「发奖牌」** 与(个人名次通道的)**「个人奖牌并入单位金牌榜」** 是否符合本场赛会规则。关掉的通道在「大项 / 小项」页的通道摘要里会标出 `不发奖牌` / `个人奖牌不并入单位榜`(见使用手册 10.2、10.4;查不出原因时按常见问题 Q94 逐条排除) -- [ ] **升级 / 首次启动后确认奖牌榜数字与上一场一致(不是全 0)**:本版新增迁移 0020,迁移只建表不回填,服务端会在下一次启动时自动全量重算补齐名次——「运行日志」里出现 `检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算` 与 `一次性全量重算完成,通道名次与奖牌榜已补齐` 是正常的,不是报错;等它跑完后刷新「积分榜 → 团体奖牌榜」,与升级前的数字(或备份里的导出件)对一遍,**不能是全 0**(见部署指南 3.4、常见问题 Q96) +- [ ] **升级 / 首次启动后确认奖牌榜数字与上一场一致(不是全 0)**:本版新增迁移 0020,迁移只建表不回填,服务端会在下一次启动时自动全量重算补齐名次——「运行日志」里出现 `检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算` 与 `一次性全量重算完成,通道名次与奖牌榜已补齐` 是正常的,不是报错;等它跑完后刷新「积分榜 → 单位奖牌榜」,与升级前的数字(或备份里的导出件)对一遍,**不能是全 0**(见部署指南 3.4、常见问题 Q96) - [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分 -- [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上) +- [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、单位奖牌榜、分项成绩表与个人积分榜上) - [ ] 同页核对**单位名单齐全、没有误删**:被「彻底删除」的单位不会出现在这个列表里(它的报名、成绩与积分仍在库里,积分榜照常显示它);发现少了就用它的编码导入一次报名表,它会重新出现(详见使用手册 10.1、常见问题 Q61–Q64) ## 5. 大屏 @@ -76,7 +76,7 @@ - [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 发布 → 大屏刷新 - [ ] 撤销发布 → 修改 → 重新发布(历史记录里能看到链路) -- [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页三个榜单的卡片头各有导出——单位总分榜「导出总明细」得到 `单位积分总明细.xlsx`、团体奖牌榜「导出奖牌明细」得到 `团体奖牌明细.xlsx`、个人积分榜「导出个人明细」得到 `个人积分总明细.xlsx`;展开某单位后还能点「导出该单位明细」得到 `单位积分明细.xlsx` +- [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页三个榜单的卡片头各有导出——单位总分榜「导出总明细」得到 `单位积分总明细.xlsx`、单位奖牌榜「导出奖牌明细」得到 `单位奖牌明细.xlsx`、个人积分榜「导出个人明细」得到 `个人积分总明细.xlsx`;展开某单位后还能点「导出该单位明细」得到 `单位积分明细.xlsx` - [ ] 断网 → 大屏仍显示缓存数据 → 恢复后自动补增量 - [ ] 重启服务端 → Console 自动拉起 diff --git a/docs/使用手册.md b/docs/使用手册.md index 7091a03..b5d0d35 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -175,7 +175,7 @@ **如果管理员关掉了「分批发布时实时更新榜单」**(见 10.5),行为会变成这样:发布后**成绩照常上屏,但名次、积分与奖牌榜都不动**——要等**该项目该组所有「正常参赛」的人都录完成绩**才一次性结算并更新榜单(退赛 / 取消资格 / 未出场的人不算在等待里)。未结算期间: - 录入页在进度条下方显示一条提示:`延迟结算:本组 X/Y 人已出成绩,名次与榜单等该组全部录完后一起更新`(本页预览的名次仍是实时的),旁边就是「立即结算」按钮; -- 该组在**分项成绩 / 最新成绩里的名次显示 `—`**(成绩照常显示)——这是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面; +- 该组在**分项成绩 / 最新成绩里的名次显示 `—`**(成绩照常显示)——这是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面;导出的「分项成绩表」「全部」「全部(单页)」里,这些行的**名次与积分列也是 `—`、状态列写「未结算」**(见 11.1); - 有人永远录不出成绩时(例如已退赛但没改状态),在录入页点 **「立即结算」**,系统按当前已发布成绩先结算一次榜单和奖牌;之后补录完还会再自动结算。 ### 4.7 撤销发布 @@ -289,7 +289,7 @@ ## 8. 积分榜 -顶部页签:**单位总分榜**、**团体奖牌榜**、**个人积分榜**;右侧搜索框可按单位或运动员过滤。 +顶部页签:**单位总分榜**、**单位奖牌榜**、**个人积分榜**;右侧搜索框可按单位或运动员过滤。 ### 8.1 单位总分榜 @@ -297,20 +297,20 @@ - 「变化」列显示名次升降(如 `↑2`、`↓1`); - 点「展开」下钻该单位的积分明细,右侧面板显示合计与每一条得分来源(项目、组别、运动员、成绩、名次、积分),每一分都可溯源。 -### 8.2 团体奖牌榜 / 个人积分榜 +### 8.2 单位奖牌榜 / 个人积分榜 -- 团体奖牌榜显示金、银、铜与合计; +- 单位奖牌榜显示金、银、铜与合计; - 个人积分榜显示运动员、单位、个人总分与奖牌数; -- 团体奖牌榜那一列的「明细」展开的是**同一个单位积分明细面板**(见 8.1),不是奖牌专属明细;但这份榜单本身有专属的逐枚奖牌导出(见 8.3); +- 单位奖牌榜那一列的「明细」展开的是**同一个单位积分明细面板**(见 8.1),不是奖牌专属明细;但这份榜单本身有专属的逐枚奖牌导出(见 8.3); - 个人积分榜**没有明细列**(不能点开下钻),但它的逐条得分来源也有专属导出(见 8.3)。 ### 8.3 导出明细(xlsx) - 三个榜单的**卡片头**各有一个导出按钮,按钮跟着当前页签切换;展开某个单位时,右侧明细卡头还有 **导出该单位明细**; - **导出总明细**(单位总分榜卡片头):导出 `单位积分总明细.xlsx`——一张带「单位」列的总表,外加每个**有数据**的单位一张分表;没有积分的单位不出分表; -- **导出奖牌明细**(团体奖牌榜卡片头):导出 `团体奖牌明细.xlsx`——第一张总表逐枚奖牌列出「单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队」,`奖牌` 列是 `金牌` / `银牌` / `铜牌`;其后每个**有奖牌**的单位一张分表(列同上、去掉「单位」,末尾一行小计 `金 X / 银 Y / 铜 Z`),没有奖牌的单位不出分表。口径与「团体奖牌榜」一致:同一个报名项在「个人+团体」这类双计通道下只记一枚奖牌,不会重复计数; +- **导出奖牌明细**(单位奖牌榜卡片头):导出 `单位奖牌明细.xlsx`——第一张总表逐枚奖牌列出「单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队」,`奖牌` 列是 `金牌` / `银牌` / `铜牌`;其后每个**有奖牌**的单位一张分表(列同上、去掉「单位」,末尾一行小计 `金 X / 银 Y / 铜 Z`),没有奖牌的单位不出分表。口径与「单位奖牌榜」一致:同一个报名项在「个人+团体」这类双计通道下只记一枚奖牌,不会重复计数; - **导出个人明细**(个人积分榜卡片头):导出 `个人积分总明细.xlsx`——第一张总表逐条列出「单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源」;其后每个**有得分**的单位一张分表(按单位分,不按人分),没有得分流水的人不出现在表里。`得分` 是记到个人的原始分,**与「个人积分榜」逐个数字一致,且不乘榜单口径里的个人项目得分折算系数**(那个系数只作用于单位总分); -- **导出该单位明细**(右侧单位明细面板头部):先展开某个单位再点,导出 `单位积分明细.xlsx`(列:序号、大项、小项、组别、运动员、名次、成绩、来源、积分、备注,末行是「合计」)。团体奖牌榜展开的也是这个单位积分明细面板,所以想按单位逐条看时同样用它; +- **导出该单位明细**(右侧单位明细面板头部):先展开某个单位再点,导出 `单位积分明细.xlsx`(列:序号、大项、小项、组别、运动员、名次、成绩、来源、积分、备注,末行是「合计」)。单位奖牌榜展开的也是这个单位积分明细面板,所以想按单位逐条看时同样用它; - 这些导出都是只读下载(访客也能用),不需要写权限;导出过程中按钮会变成「导出中…」并禁用,避免重复下载。 ### 8.4 推送明细到大屏 @@ -326,12 +326,14 @@ | 区块 | 操作 | | --- | --- | -| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 实时赛况 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 | +| 切页 | 选择「总积分榜 / 单位奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 实时赛况 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 | | 锁定与刷新 | **锁定**:停止自动轮播并停在当前页;**解锁**:恢复轮播;**强制刷新**:让所有大屏重新拉取快照,用于排障 | | 公告 | 输入公告文字,设置「滚动时长」(5–120 秒,实际是全屏展示的秒数),点「推送公告」;点「清除公告」撤下当前公告 | 页面顶部徽标显示当前是「轮播中」还是「已锁定」。 +> 「**单位奖牌榜**」这一页旧称「**团体奖牌榜**」(容易和「团体项目的奖牌」混淆,所以改名),大屏上、切页下拉里现在一律显示「单位奖牌榜」;旧名仍被当作别名接受,老的大屏地址与老配置(`display.pages`)不需要改。 + > **权限**:本页的**切页、公告、锁定 / 解锁、强制刷新**(服务端指令)记录员及以上都能用;**开窗 / 收窗 / 重新排布**由服务管理程序在本机执行,不受角色限制(该卡片只在从服务管理窗口打开的面板里可用);只有「实时赛况」卡片(模式与当前项目)属于赛事设置,**仅管理员能保存**,非管理员看到的是整组置灰 + 一行提示条。 **公告**:点「推送公告」后所有大屏先**全屏**展示这条公告,到时间后自动退回原来的页面(没锁定就继续轮播),公告正文留在底栏的「公告」槽位继续显示,直到被新公告替换或点「清除公告」。公告卡片顶部徽标显示「当前公告:有 / 无」,徽标下面那一行就是当前公告正文(没有公告时显示「暂无公告(大屏底栏显示占位)」)。公告会随赛事设置保存:新开的大屏窗口、断线重连之后,会先把当前公告再全屏播一遍(默认 15 秒)后留在底栏;点「清除公告」后底栏回到「暂无公告」占位。 @@ -407,13 +409,13 @@ - 该单位**不再出现在默认的参赛单位列表**里,报名选择(含团体报名、运动员的「所属单位」下拉)里也选不到; - 它的**报名、成绩、积分流水、人工调整、纪录与名下运动员全部保留**——消失的只是这个单位本身; -- **积分榜照旧显示它**:单位本来有分数的话,总积分榜、团体奖牌榜上仍有它的名字与分数。这是刻意设计(数据没删,分数就不该凭空消失),不是故障; +- **积分榜照旧显示它**:单位本来有分数的话,总积分榜、单位奖牌榜上仍有它的名字与分数。这是刻意设计(数据没删,分数就不该凭空消失),不是故障; - **再次导入报名表时,如果用到这个单位的编码,它会自动复活**并重新出现在列表里——这里和人员不同:删掉的人再次导入是**以新档案建档**(旧数据回不来),删掉的单位则是**原来那条记录直接恢复**,报名、成绩与积分一直都在(见 10.7); - 单位名下还有运动员时同样可以删,这些运动员不会被一起删掉,但他们编辑起来会有些不便(见常见问题 Q64)。 > ⚠️ **别和运动员的「彻底删除」混为一谈**:两个页面上的按钮同名,行为完全不同。运动员的「彻底删除」是**真删除**——人员档案连同他名下的报名、成绩与积分流水一起从库里删掉,并重算受影响小项的排名与积分(见 10.6);参赛单位的「彻底删除」是**假删除**——业务数据一条不删,只让单位从列表里消失,积分榜不变。 -> 上传的 logo 会显示在**大屏**的总积分榜、团体奖牌榜、分项成绩与个人积分奖牌榜上(个人积分奖牌榜显示在运动员姓名前面);没传 logo 的单位不占位,队名不会因此错位。管理端页面不显示 logo。 +> 上传的 logo 会显示在**大屏**的总积分榜、单位奖牌榜、分项成绩与个人积分奖牌榜上(个人积分奖牌榜显示在运动员姓名前面);没传 logo 的单位不占位,队名不会因此错位。管理端页面不显示 logo。 ### 10.2 大项 / 小项 @@ -427,7 +429,7 @@ 小项表单下方的「选项」里,"是否计分"旁边有一个 **「该项启用奖牌结算」** 勾选框,**默认勾上**。 - **勾上(默认)**:这个小项按名次发奖牌——第 1 名金、第 2 名银、第 3 名铜,和以前一样; -- **取消勾选**:这个小项的**名次与积分照常计算、照常上屏**,但**一块奖牌都不发**——金牌榜(团体奖牌榜)与个人积分榜的奖牌列里都不再出现它。 +- **取消勾选**:这个小项的**名次与积分照常计算、照常上屏**,但**一块奖牌都不发**——金牌榜(单位奖牌榜)与个人积分榜的奖牌列里都不再出现它。 什么时候该关掉它?**"算分但不发牌"的项目**——例如赛会规则只记名次分、不设金银铜的项目,或者现场临时决定不发牌、但分数仍要计入单位总分的项目。 @@ -481,7 +483,7 @@ | 开关 | 默认 | 关掉之后 | | --- | --- | --- | | **发奖牌** | 打开(该通道的名次发奖牌) | 这条通道算出来的名次**只出积分、不发奖牌**:名次与分数照旧,金牌榜与个人榜里不再为它记牌 | -| **个人奖牌并入单位金牌榜** | 打开 | 只对「名次范围 = 个人名次」的行显示。关掉后这条通道的个人奖牌**仍然进个人积分榜的奖牌列**,但**不计入单位的金牌榜**(团体奖牌榜 / 单位总分榜的金银铜) | +| **个人奖牌并入单位金牌榜** | 打开 | 只对「名次范围 = 个人名次」的行显示。关掉后这条通道的个人奖牌**仍然进个人积分榜的奖牌列**,但**不计入单位的金牌榜**(单位奖牌榜 / 单位总分榜的金银铜) | 怎么用: @@ -586,7 +588,7 @@ - `未出场` / `未到` / `缺席` → 未出场。 留空即「正常参赛」。真正认不出的写法会给出中文行级错误、该行跳过,例如「第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)」; 6. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号与号码布; -7. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); +7. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 32MB;xlsx 只解析第一个工作表); - **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别; 8. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 @@ -623,19 +625,22 @@ | 导出项 | 内容 | 文件名 | | --- | --- | --- | -| 总表 | 总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿 | `总积分榜.xlsx` | +| 总表 | 总积分榜 + 单位奖牌榜 + 个人积分奖牌榜,一个工作簿 | `总积分榜.xlsx` | | 全部 | 总表 + 全部分项,多 sheet 工作簿,适合归档打印 | `赛事成绩总表.xlsx` | | 全部(单页) | 所有单位、所有项目、所有成绩**平铺在一张表**里(一个工作簿只有一张表),适合筛选与二次统计 | `赛事成绩总表(单页).xlsx` | | 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` | | 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` | | 报名名单 | **本页下方的「报名名单」卡片**:按小项 / 组别 / 单位 / 批次筛选后导出报名数据(不填 = 全部)。13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、**号码布**、**批次**、性别、年龄、状态、备注;号码布按文本导出,前导零不会丢 | `报名名单.xlsx` | -| 团体奖牌明细 | **在「积分榜 → 团体奖牌榜」页操作**:逐枚奖牌列出单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队,每个有奖牌的单位一张分表(末尾小计) | `团体奖牌明细.xlsx` | +| 单位奖牌明细 | **在「积分榜 → 单位奖牌榜」页操作**:逐枚奖牌列出单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队,每个有奖牌的单位一张分表(末尾小计) | `单位奖牌明细.xlsx` | | 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` | - 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导); -- **录了但还没发布的成绩也会占一行**(「分项成绩表」「全部」「全部(单页)」都是):这类行的成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」,人不会从表上消失;**完全没有录成绩的报名行**(连草稿都没有)**照样占一行**——名次 / 成绩 / 积分三列都写 `—`(不是空、也不是 `0`)、状态列写「未录入」; +- **三种「还没出结果」的行也照样占一行**(「分项成绩表」「全部」「全部(单页)」都是,逐格口径完全一致): + - **录了但还没发布**:成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」; + - **完全没有录成绩**(连草稿都没有):名次 / 成绩 / 积分三列都写 `—`(不是空、也不是 `0`)、状态列写「未录入」; + - **成绩已发布、但那一组还没结算**(通常是管理员关掉了「分批发布时实时更新榜单」,见 10.5):**成绩照常打印**(这一条确实跑出来了),名次与积分列写 `—`、状态列写「未结算」——这样一眼能看出是「还没结算」,而不是「算出来就是 0 分」; - **「全部」与「全部(单页)」的区别**:两者内容同源,覆盖的都是全部小项的全部报名 / 成绩;「全部」把每个小项放成一张分表(多 sheet 工作簿),「全部(单页)」把所有行平铺进同一张表,每行前面补「大项 / 小项 / 组别」三列以便脱离表名也能看懂。单页表的列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注; -- **「团体奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响; +- **「单位奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响; - 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。 ### 11.2 赛事项目包 @@ -695,7 +700,7 @@ - **表头行可以不在第一行**:从系统导出的包表头在第 4 行(上面是标题与副标题),手工整理的表表头在第 1 行,两种都能识别(识别范围是前 20 行); - **枚举列要填系统里的英文规范值**(表头是中文,容易踩):类型 `individual` / `team` / `match` / `individual_team`,录入方式 `auto` / `manual`,成绩类型 `time` / `distance` / `count` / `score` / `none`,比较方式 `asc` / `desc` / `none`,小项状态 `pending` / `ongoing` / `finished` / `published`,平均口径 `valid` / `all`;布尔列(启用 / 计分 / 计破纪录)填 **是 / 否**;组别表的「性别」填 **男 / 女**(列在表里但格子留空 = 不改动;新建的组别留空 = 不限); - **最稳的用法**:先「导出基础数据包」,在导出件上直接改,再导回来——表头与取值天然对得上;手工整理的表格请照导出件的表头写; -- 被「彻底删除」的单位不在导出件里;导入**不会**把它翻出来(已有的隐藏记录只更新字段,仍是隐藏状态)。 +- 被「彻底删除」的单位不在导出件里;导入时如果有一行命中它的编码,**这一行整行跳过**(不更新它的任何字段,也不会把它恢复出来),并在导入结果里报一句 `单位 第 N 行:单位 XX 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先恢复该单位`——错误计入「错误 N 条」,其余合法的行照常写入。真要让这个单位回来,先到「基础数据 → 参赛单位」用「恢复」把它放回列表(见 10.1),再导入。 --- @@ -714,7 +719,7 @@ 审计日志,只读、不可删除,记录谁在什么时候改了什么。 -1. 可按「**动作**」「实体」筛选,点「清除筛选」复位。**动作是子串模糊匹配、由服务端在全库范围执行**(比如填 `entry.`、`import`、`team.purge` 都能筛出一批),**实体筛选与分页作用于返回的最近 200 条窗口**; +1. 可按「**动作**」「实体」筛选,点「清除筛选」复位。**动作是子串模糊匹配、由服务端在全库范围执行**(填 `import` 能筛出**全部导入类动作**——报名名单导入、冲突决策、基础数据包、规则包、赛事包都认这个词;填 `entry` 能筛出成绩录入里的批次与批量操作;填 `team.purge` 能筛出单位删除),**实体筛选与分页作用于返回的最近 200 条窗口**; 2. 列表显示时间、操作人、IP、动作、实体、请求 ID;关键动作会带「关键」标记; 3. 点某行「对比」展开,查看改动前后的 JSON 差异; 4. **动作筛选不受 200 条窗口限制**(服务端按全库匹配后再取回),实体筛选与翻页只在窗口内; @@ -744,7 +749,7 @@ - 列表显示备份文件名、来源、大小、创建时间; - 点「立即备份」马上生成一份; - 服务端默认每 5 分钟自动备份一次,自动、手动、发布前备份都按 `backup.keep`(默认 20 份)滚动保留;⚠️ `deploy\backup.bat` 额外产出的 `*-uploads-*.zip`(上传图片与字体)**不在**这个滚动清理里,要手工清理; -- 每行备份都有**「恢复」**按钮(**仅管理员**):用那份备份覆盖当前库——服务端会先自动留一份「恢复前安全副本」(`pre-restore-…db`,不参与滚动清理),替换文件后优雅重启,页面自动等待刷新。**恢复是覆盖式的**(会丢备份时间点之后的改动),**恢复系统库会连账号与会话一起换掉**,**不会带回上传的图片与字体**;右侧「恢复说明」列出细节与停机手工恢复步骤(详见部署指南第 8 节)。 +- 每行备份都有**「恢复」**按钮(**仅管理员**):用那份备份覆盖当前库——服务端会先自动留一份「恢复前安全副本」(`pre-restore-…db`,不参与滚动清理,**每类库只保留最近 5 份**,更旧的会被后续恢复自动清理),替换文件后优雅重启,页面自动等待刷新。**恢复是覆盖式的**(会丢备份时间点之后的改动),**恢复系统库会连账号与会话一起换掉**,**不会带回上传的图片与字体**;右侧「恢复说明」列出细节与停机手工恢复步骤(详见部署指南第 8 节)。 ### 14.3 赛事信息 @@ -849,7 +854,7 @@ | 页面 | 内容 | | --- | --- | | 总积分榜 | 名次、单位(单位 logo + 单位名)、总分,以及名次升降箭头(约 2 秒后淡出) | -| 团体奖牌榜 | 名次、单位(带单位 logo)、金 / 银 / 铜、合计 | +| 单位奖牌榜 | 名次、单位(带单位 logo)、金 / 银 / 铜、合计 | | 个人积分奖牌榜 | 名次、姓名(姓名前显示所属单位 logo)、单位、总分、奖牌 | | 分项成绩 | 最新已完成项目的成绩,单位列名字前显示单位 logo | | 实时赛况 | 当前正在进行项目的实时排名(见下方说明) | diff --git a/docs/升级说明.md b/docs/升级说明.md index 94e7dc7..51468d7 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,62 @@ --- -## dev_0.1.8(当前版本) +## dev_0.2.0(当前版本) + +### 一句话 + +**没有需要迁移的数据**:这一版是 100 多项缺陷与体验修复的合集(含一次改名、几处口径收紧)。升级后榜单数字应当与升级前一致。 + +### ① 三件升级前必须知道的事 + +**1)「团体奖牌榜」改名为「单位奖牌榜」**,导出件也跟着改了名: +- 界面上:大屏控制页的切页下拉、大屏奖牌榜页标题、管理端「积分榜」页的页签,都叫 **单位奖牌榜**; +- 导出件:下载文件名由 `团体奖牌明细.xlsx` 改为 **`单位奖牌明细.xlsx`**,Excel 里的工作表名同步改为 **`单位奖牌榜`** / **`单位奖牌明细`**。 + → **如果你把旧文件名写进了对账脚本、打印模板或交接文档,记得改**。 +- **旧名仍然可用**:老链接(`?page=团体奖牌榜`)与老配置(`display.pages` 里写 `medals` 或旧名)照常生效,不用改。 + +**2)审计日志的动作前缀统一了**(`entries.import` → `entry.import`,`entries.import.resolve` → `entry.import.resolve`): +- **升级前落库的历史记录不会跟着改**——它们仍写着旧前缀。所以**查历史时请用子串 `import` 筛**(新旧都能筛到);用 `entry.` 只能筛到升级之后的记录。审计是只追加的,我们不会改写历史。 + +**3)密码长度改成按「字符」数**(原来是按字节): +- 下限仍是 8 **位**,但现在按字符算:**7 个汉字(原来 21 字节能过)现在会被拒**;`3 个汉字 + 5 个字母`(8 字符)正常通过。 +- 上限仍是 72 字节(bcrypt 的边界),文案里会分别写清「位」与「字节」。 + +### ② 现场会看到的变化 + +- **导出件里的「未结算」**:开了延迟结算时,「已发布但那一组还没结算」的行,导出的「分项成绩表 / 全部 / 全部(单页)」现在写 **名次 `—` / 积分 `—` / 状态 `未结算`**(成绩照常打印)。原来这三列是「空名次 + 0 分 + 有效」,读起来像「算出来就是 0 分」。 +- **基础数据包导入遇到「已彻底删除的单位」**:那一行**整行跳过**并在导入结果里说明原因(不再更新它的字段、也不会把它翻出来);要让单位回来,先到「基础数据 → 参赛单位」勾选「显示已彻底删除的单位」再点「恢复」。 +- **报名名单导入换了单位会明确提示**:「第 N 行:某某 换了单位,原单位 A 在本组别的报名已移出,已改在单位 B 加入」——数据行为没变(一直是这样),现在会说出来了。 +- **「从已有名单勾选报名」有冲突时不再报「已加入」**:会明确提示「有 N 行没有加入,需要逐条确认」,并在页内列出是哪几行。 +- **导出核对更省事**:单位积分明细的「成绩」列与分项成绩表、个人明细**逐字一致**(原来少个单位,如 `11.58` vs `11.58 秒`);空单位的「单位积分明细」也会打印末行「合计」。 +- **收不到回执的危险操作不再误导**:一键恢复请求超时/断连时,提示由「恢复未执行」改为「**没收到回执,恢复可能已经生效**」,并单独放宽了超时时间;两个管理员同时点恢复时,第二个会拿到「正在恢复,请稍后」。 + +### ③ 部署相关的两处 + +- **赛事包上传上限是 512MB**(名单 CSV 与基础数据包那一档仍是 32MB,赛事包刻意放宽——它要装字体、背景图、各单位 logo;字体单个就允许 20MB)。超限会提示「解析上传失败」。 +- **反向代理(nginx 等)**:若以前遇到「登录一定 403」,本版起错误提示会指明原因与修法;正解是让代理透传原 Host(`proxy_set_header Host $host;`),或配置可信代理并把原 Host 透传在 `X-Forwarded-Host` 上。**不要**为了绕过它把校验放宽——那等于开登录 CSRF 的口子。 +- 顺带:**丢了 CSRF Cookie 不再等于「能看不能写」**——管理面板会自动补发,症状消失后无需再刷新页面。 + +### ④ 升级后 5 分钟核对 + +1. 打开服务管理窗口的运行日志:**这一次升级没有那条「通道名次自愈」记录是正常的**(本版没有新迁移,不需要重算);若看到它,说明你从上上版直接跳过来,等它跑完即可; +2. 刷新积分大屏与管理面板:**单位总分榜、单位奖牌榜、个人积分榜的数字与升级前一致**; +3. 随便挑一个小项导出一次「分项成绩表」,确认表头与列都对; +4. 用《上线检查清单》过一遍。 + +### ⑤ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 榜名/导出文件名的变化 | 本文件 ①;《使用手册》8.2、11.1 | +| 导出里三种「还没出结果」的行 | 《使用手册》11.1;《常见问题》Q90 | +| 基础数据包遇到已删除的单位 | 《使用手册》11.4;《常见问题》Q88 | +| 恢复与安全副本保留份数 | 《部署指南》8.3;《常见问题》Q40 | +| 反代/登录 403 | 《部署指南》7.x 与应用内提示 | + +--- + +## dev_0.1.8 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index a106b3c..000bc67 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -128,7 +128,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - 公告保存在赛事设置里(`display.announce_text` / `display.announce_seconds`),所以服务端重启、大屏窗口重开、断线重连之后仍然显示;重开的大屏会先把当前公告全屏播 15 秒,再留在底栏。 **Q26:大屏上的单位图标显示在哪?没显示怎么办?** -配了 logo 的参赛单位会在这几处显示单位图标:总积分榜(单位名左侧)、团体奖牌榜(单位名左侧)、分项成绩(单位列名字左侧)、个人积分奖牌榜(运动员姓名左侧)。图标按行内文字大小缩放(1em),不会撑开行距;没有 logo 的单位不占位,图片临时加载不出来时保留占位、不会出现破图,队名不会跳动。 +配了 logo 的参赛单位会在这几处显示单位图标:总积分榜(单位名左侧)、单位奖牌榜(单位名左侧)、分项成绩(单位列名字左侧)、个人积分奖牌榜(运动员姓名左侧)。图标按行内文字大小缩放(1em),不会撑开行距;没有 logo 的单位不占位,图片临时加载不出来时保留占位、不会出现破图,队名不会跳动。 没显示就去「基础数据 → 参赛单位 → 编辑 → Logo 路径」上传或填写该单位的 logo(管理端页面本身不显示 logo,只在大屏上显示)。上传的图片存在该赛事的 `data\event_projs\<项目名>\uploads\` 里,会随赛事项目包一起导出 / 导入。 @@ -194,7 +194,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。⚠️ 但 `deploy\backup.bat` 另外产出的 `*-uploads-*.zip`(上传图片与字体)**不在这个滚动清理里**,不会被自动删,需要手工清理,建议每个项目至少留最新一份。 **Q40:怎么恢复备份?** -**管理端有一键恢复(仅管理员)**:到「系统设置 → 备份」,在列表里找到要退回到的那份(`pre-restore-…` 是系统自动留的「恢复前安全副本」),点该行的**「恢复」**并过危险确认框。服务端会先给当前库拍一份安全副本,再替换库文件并优雅重启,页面自动等待刷新。「恢复系统库」会连账号与会话一起换掉,重启后需要用那个时间点的账号重新登录;**恢复不会带回上传的图片与字体**(它们在 `uploads\` 里,不在 `.db` 内)。恢复是覆盖式的,备份时间点之后的改动都会丢。 +**管理端有一键恢复(仅管理员)**:到「系统设置 → 备份」,在列表里找到要退回到的那份(`pre-restore-…` 是系统自动留的「恢复前安全副本」),点该行的**「恢复」**并过危险确认框。服务端会先给当前库拍一份安全副本(**每类库只保留最近 5 份**,更旧的会被后续恢复自动清理),再替换库文件并优雅重启,页面自动等待刷新。「恢复系统库」会连账号与会话一起换掉,重启后需要用那个时间点的账号重新登录;**恢复不会带回上传的图片与字体**(它们在 `uploads\` 里,不在 `.db` 内)。恢复是覆盖式的,备份时间点之后的改动都会丢。 没有 Console 或恢复不了时的兜底是停机手工替换数据库文件:在 Console 里停止服务,到 `<数据目录>\event_projs\<项目名>\` 把目标备份复制为 `event.db`(覆盖原文件),删掉同目录的 `event.db-wal` 与 `event.db-shm`,再启动服务,详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。 @@ -221,7 +221,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 ## 导出与导入 **Q43:能导出哪些文件?** -「导出 → 结果导出」页有六类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`),以及可筛选的**报名名单**(`报名名单.xlsx`,见 Q87)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、团体奖牌榜卡片头的**导出奖牌明细**(`团体奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)、**基础数据包**(xlsx,见 Q88)与规则包(YAML)。 +「导出 → 结果导出」页有六类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`),以及可筛选的**报名名单**(`报名名单.xlsx`,见 Q87)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、单位奖牌榜卡片头的**导出奖牌明细**(`单位奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)、**基础数据包**(xlsx,见 Q88)与规则包(YAML)。 **Q44:导出文件保存在哪?** 浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。 @@ -353,7 +353,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 两种常见原因:**管理员恢复了它**,或者**导入把它带回来了**。 - **恢复**:只有**管理员**能在参赛单位页勾选「**显示已彻底删除的单位**」把已删除的单位翻出来(标「已删除」),点该行的「**恢复**」即可原样放回列表;录入员(recorder)界面上看不到这个开关,删错了要**找管理员恢复**。恢复是幂等的,本来就还在时会提示「本就未被彻底删除」;若该编码已被另一个可见单位占用(只能来自手工改库),会拒绝并说明原因。 -- **导入带回**:报名表里再次用到这个单位的编码(`team_code`),系统会把它**原样复活**——清掉删除标记、恢复成启用,重新出现在参赛单位列表里。因为单位编码是唯一的,系统按编码找到的就是原来那一行,所以**不会新建第二个同名单位**,原有的报名、成绩与积分也都还在。 +- **导入带回**:报名表里再次用到这个单位的编码(`team_code`),系统会把它**原样复活**——清掉删除标记、恢复成启用,重新出现在参赛单位列表里。因为单位编码是唯一的,系统按编码找到的就是原来那一行,所以**不会新建第二个同名单位**,原有的报名、成绩与积分也都还在。(**注意**:这只针对**报名表**导入;用**基础数据包**导入不会复活它,见 Q88。) 这和人员的规则**看着像、结果不同**:删掉的人再次导入是**以全新档案建档**,旧数据不会回来(见 Q58);删掉的单位再次导入是**原来那个单位直接复活**,旧数据一直都在。 @@ -362,7 +362,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 **Q63:为什么积分榜里还能看到已删除的单位?** -这是**刻意设计**,不是 bug。「彻底删除」只是把单位从参赛单位列表里移除的假删除——它的报名、成绩与积分流水**一条都没删**。既然分数还在,积分榜(总积分榜、团体奖牌榜、分项成绩表)就会照旧列出这个单位的名字与分数,否则总分和名次就对不上了。 +这是**刻意设计**,不是 bug。「彻底删除」只是把单位从参赛单位列表里移除的假删除——它的报名、成绩与积分流水**一条都没删**。既然分数还在,积分榜(总积分榜、单位奖牌榜、分项成绩表)就会照旧列出这个单位的名字与分数,否则总分和名次就对不上了。 所以「单位列表里没有它」不等于「它的历史成绩不存在」,两处不一致是正常的。如果确实希望它的分数不再影响榜单,那不是「彻底删除单位」能解决的事——它动的只有列表里的条目,不碰任何成绩数据。 @@ -406,17 +406,17 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 1. **只导一个单位**:到「积分榜」页展开该单位的明细,点右侧明细面板头部的「导出该单位明细」,得到 `单位积分明细.xlsx`(逐条列出每一分的来源,末行是「合计」); 2. **一次导全部单位**:在「积分榜 → 单位总分榜」的**卡片头**点「导出总明细」,得到 `单位积分总明细.xlsx`——一张带「单位」列的总表,外加每个**有数据**的单位一张分表(没有积分的单位不出分表)。 -团体奖牌榜那一列的「明细」展开的仍是**同一个单位积分明细面板**,所以想按单位看时也可以在那上面点「导出该单位明细」;但团体奖牌榜本身另有专属的逐枚奖牌导出(卡片头「导出奖牌明细」,见 Q71),个人积分榜也有专属的逐条得分导出(卡片头「导出个人明细」,见 Q70、Q72)。另外「导出 → 结果导出」里的「单位积分明细」也能先选一个单位再导出。这些导出都是只读下载,访客也能用。 +单位奖牌榜那一列的「明细」展开的仍是**同一个单位积分明细面板**,所以想按单位看时也可以在那上面点「导出该单位明细」;但单位奖牌榜本身另有专属的逐枚奖牌导出(卡片头「导出奖牌明细」,见 Q71),个人积分榜也有专属的逐条得分导出(卡片头「导出个人明细」,见 Q70、Q72)。另外「导出 → 结果导出」里的「单位积分明细」也能先选一个单位再导出。这些导出都是只读下载,访客也能用。 **Q70:个人积分榜能导出明细吗?** 能。个人积分榜的**卡片头**有「导出个人明细」按钮,导出 `个人积分总明细.xlsx`:第一张总表逐条列出「单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源」,其后每个**有得分**的单位一张分表(按单位分,不按人分;没有得分流水的人不出现在表里)。`得分` 是记到个人的原始分,与「个人积分榜」逐个数字一致,**不受榜单口径里的个人项目得分折算系数影响**(那个系数只作用于单位总分),详见 Q72。 -**Q71:团体奖牌榜能导出每一块奖牌的来源吗?** -能。到「积分榜 → 团体奖牌榜」,点**卡片头**的「导出奖牌明细」,得到 `团体奖牌明细.xlsx`: +**Q71:单位奖牌榜能导出每一块奖牌的来源吗?** +能。到「积分榜 → 单位奖牌榜」,点**卡片头**的「导出奖牌明细」,得到 `单位奖牌明细.xlsx`: - 第一张总表逐枚列出「单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队」,`奖牌` 列是 `金牌` / `银牌` / `铜牌`; - 其后每个**有奖牌**的单位一张分表,列与总表相同但去掉「单位」,末尾多一行小计 `金 X / 银 Y / 铜 Z`;没有奖牌的单位不出分表; -- 口径与「团体奖牌榜」一致:同一个报名项在「个人+团体」这类双计通道下只记一枚奖牌,导出不会重复计数。 +- 口径与「单位奖牌榜」一致:同一个报名项在「个人+团体」这类双计通道下只记一枚奖牌,导出不会重复计数。 **Q72:个人积分明细导出的数字跟个人积分榜对得上吗?会不会受榜单口径系数影响?** 对得上,而且**不受系数影响**。「导出个人明细」(`个人积分总明细.xlsx`)里的 `得分` 是**记到个人的原始分**,与「个人积分榜」逐个数字一致;「基础数据 → 积分模板 → 榜单口径」里的个人项目得分折算系数(键名 `standings.individual_to_team_weight`,默认 1)只在汇总环节把个人分折算进单位总分,**不会改变个人明细里的数字**——比如把系数改成 0.5,个人明细的得分仍是原始分,只有单位总分变小(见 Q18)。 @@ -540,7 +540,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 能。到「导出 → 基础数据」页: - **导出**:一个工作簿四张工作表(单位 / 大项 / 小项 / 组别,组别表带所属小项编码); -- **导入**(**仅管理员**,只认 `.xlsx`):按编码匹配,**存在就更新、不存在就新建,绝不删除**(表里没有的行不会被删、也不会被停用)。先点「预览差异」看「新增 / 更新 / 无变化 / 错误」与逐行变化,确认后再点「**确认写入**」;**有错的行会跳过、合法的行照常写入**,行级错误是中文并带真实 Excel 行号(如 `单位 第 3 行:单位编码必填`)。 +- **导入**(**仅管理员**,只认 `.xlsx`):按编码匹配,**存在就更新、不存在就新建,绝不删除**(表里没有的行不会被删、也不会被停用)。先点「预览差异」看「新增 / 更新 / 无变化 / 错误」与逐行变化,确认后再点「**确认写入**」;**有错的行会跳过、合法的行照常写入**,行级错误是中文并带真实 Excel 行号(如 `单位 第 3 行:单位编码必填`)。已「彻底删除」的单位不在导出件里,命中它的编码那一行会**整行跳过并报错**(不更新它的任何字段、也不会把它恢复出来,想让单位回来先用「参赛单位」页的「恢复」,见 Q62)。 要点:**列不在表里 = 不动该字段**(**布尔列也一样**,缺整列 ≠ 取默认值);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写)。**只有枚举列例外:列在表里但格子空 = 不改动**(小项的类型 / 结果模式 / 结果类型 / 比较 / 状态 / 平均口径、组别的性别),留空就沿用原来的值,只有**新建的行**才用默认值(新建组别时性别留空 = 不限)——这样误清一格不会把已发布小项打回「待录入」、也不会把「团体」改成「个人」。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。 @@ -550,7 +550,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 ⚠️ 但它不是「永不掉线」:超过设定的有效期一直没操作(浏览器关掉、机器关机过夜),再打开仍要重新登录。改设置不会把正在用的会话当场踢下线:已登录会话的到期时间不会在改设置的一刻改变,它会在**下一次操作时按新的有效期续期**(调长立即顺延,例如 12 小时改 72 小时后,正在用的会话下次有请求就顺延成 72 小时;调短也只影响之后的续期)。 **Q90:发布后成绩上屏了,为什么积分榜没变、名次还是「—」?** -多半是管理员把「榜单口径」里的**「分批发布时实时更新榜单」关掉了**(默认是开的),也就是**延迟结算**:分批发布时只把成绩上屏,**积分与名次等该项目该组所有批次都录完才一次性结算**(报名状态不是「正常参赛」的人——退赛 / 取消资格 / 未出场——不算在等待里)。未结算组的**名次显示 `—`**(成绩照常显示)是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面。 +多半是管理员把「榜单口径」里的**「分批发布时实时更新榜单」关掉了**(默认是开的),也就是**延迟结算**:分批发布时只把成绩上屏,**积分与名次等该项目该组所有批次都录完才一次性结算**(报名状态不是「正常参赛」的人——退赛 / 取消资格 / 未出场——不算在等待里)。未结算组的**名次显示 `—`**(成绩照常显示)是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面。导出的「分项成绩表」「全部」「全部(单页)」对这类行也是**名次与积分列写 `—`、状态列写「未结算」**(成绩照常打印),见使用手册 11.1。 想让榜单立刻跟上:等该组录完会自动结算;确实有人永远录不出成绩时,在**成绩录入页**点 **「立即结算」**,按当前已发布成绩先结算一次。想改回实时结算:管理员到「基础数据 → 积分模板 → 榜单口径」,把「分批发布时实时更新榜单」勾上并点「保存结算方式」(见使用手册 10.5)。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index 1338dba..b908f68 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -201,7 +201,7 @@ | 第 N 行:status「xxx」无法识别 | `status` 填了系统不认的词(4 个标准词以外的英文,或第三节没列的中文) | 改成 `registered` / `scratched` / `dq` / `dns`(大小写都行)或第三节列的中文别名,也可以留空(= 正常参赛);改完重新上传 | | 第 N 行:heat 需要是 1-999 的整数(留空表示未分批) | `heat` 填了非整数、0、负数或超过 999 的值(如 `第1批`、`一组`、`1000`) | 改成 1–999 的整数,或者整格留空(= 未分批);批次只影响录入分组,不参与算分 | | 提示「冲突」,人员没有写入 | 按证件号(或号码布、「姓名 + 单位」唯一命中)的人在系统里已有记录,但姓名 / 证件号 / 性别 / 年龄 / 单位 / 号码布跟表里**对不上** | 在导入结果里逐条选「保留已有数据」或「使用新数据」,选完再提交 | -| 文件传不上去 | 超过 16MB | 拆分上传,或改用 xlsx | +| 文件传不上去 | 超过 32MB | 拆分上传,或改用 xlsx | | 只导入了第一张表 | xlsx 只读第一个工作表 | 把要导入的数据放在第一个工作表里 | --- diff --git a/docs/部署指南.md b/docs/部署指南.md index a3cbeac..ba34c31 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -111,6 +111,7 @@ deploy\build.bat - Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行; - **服务端日志也在数据目录下**:`<数据目录>\logs\` 里分 `operations\`(放行类)与 `interceptions\`(拦截类)两个子目录,按天 + 按大小轮转、自动清理旧文件,可用 `config.yaml` 的 `log:` 段改位置与保留份数(见第 10 节)。它在安装目录之外,覆盖安装不会丢; +- **恢复未完成标记**:数据目录根部的 `pending_restore.json` 只在「一键恢复」**中途异常中断**时留下(正常走完会被清掉),里面记着备份文件名、目标库、恢复前的安全副本与操作员。服务端下次启动会读它并在运行日志里给一条 WARN(含处置建议),**只读不删**——它是现场判断「这台机器上发生过一次没走完的恢复」的唯一线索。平时这个文件不存在,看到它就该按日志提示核对数据库; - **上传资源随赛事项目走**:赛事会标、大屏背景图、单位 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 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份; @@ -369,7 +370,8 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da - 除内置屏外的每块屏各开一个 kiosk 全屏窗口; - 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配; - 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识; -- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`live`(实时赛况)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。把 `live` 加进 `display.pages`,就能把某块屏固定成「实时赛况」(`live` 也认 `live-results` / `实时赛况` / `实时` / `赛况` 这些别名,与其它页一样)。 +- 可用的页面值:`standings`(总积分榜)、`medals`(单位奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`live`(实时赛况)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。把 `live` 加进 `display.pages`,就能把某块屏固定成「实时赛况」(`live` 也认 `live-results` / `实时赛况` / `实时` / `赛况` 这些别名,与其它页一样)。 +- 这一页现在叫 **「单位奖牌榜」**(旧称「团体奖牌榜」容易和「团体项目的奖牌」混淆,已改名);**旧名 `团体奖牌榜` 仍作为别名被接受**,老配置里的 `display.pages`、大屏地址里 `page=团体奖牌榜` 这类老链接都照常生效,不需要改。 ### 7.5 在管理端单独控制某块屏 @@ -393,7 +395,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da ### 7.7 大屏画面里的调试标识与单位 logo - **调试标识**:只有**窗口化且没有全屏**的大屏窗口才会显示一行「实例 … · 屏 …」;**多屏 kiosk 全屏完全不带调试标识**,单屏窗口化窗口按 F11 全屏后它也会连同自绘标题栏一起隐藏。数据版本号角标 `v` 已移除,任何情况下都不再显示(要核对某块屏是否跟上服务端,看管理端仪表盘「实时连接」里的「版本 N」)。要一个完全干净的展示画面,就用多屏 kiosk; -- **单位 logo**:参赛单位的 logo 会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上(个人榜显示在姓名前);没上传 logo 的单位不占位。logo 在「基础数据 → 参赛单位」里上传,随赛事项目包一起导出/导入(见 8.5)。 +- **单位 logo**:参赛单位的 logo 会显示在总积分榜、单位奖牌榜、分项成绩表与个人积分榜上(个人榜显示在姓名前);没上传 logo 的单位不占位。logo 在「基础数据 → 参赛单位」里上传,随赛事项目包一起导出/导入(见 8.5)。 ### 7.8 大屏窗口的自动行为 @@ -454,7 +456,7 @@ http://<主机IP>:19540/display **首选:管理端「一键恢复」(仅管理员)。** 到「系统设置 → 备份」,在备份列表里找到要退回到的那一份(列表会显示时间、原因、大小;`pre-restore-…` 是恢复前自动留的安全副本),点该行的**「恢复」**: 1. 先过危险确认框(会写明要覆盖的是系统库还是赛事库、会丢弃备份时间点之后的改动); -2. 服务端先**给当前库拍一份「恢复前安全副本」**(`pre-restore-<库>-<时间戳>.db`,不参与 20 份滚动清理,选错时间点还能用它退回),再替换库文件,然后**优雅退出并由 Console 自动拉起**; +2. 服务端先**给当前库拍一份「恢复前安全副本」**(`pre-restore-<库>-<时间戳>.db`,不参与 20 份滚动清理,选错时间点还能用它退回;**每类库只保留最近 5 份**,更旧的会在后续恢复时被自动清理),再替换库文件,然后**优雅退出并由 Console 自动拉起**; 3. 页面显示「正在重启服务端…」并自动等待刷新(最多 60 秒;超时就打开服务管理窗口确认状态后手动刷新); 4. **恢复是覆盖式的**:备份时间点之后的所有改动都会丢。恢复系统库还会连**账号与会话**一起换掉,重启后需要用那个时间点的账号重新登录; 5. ⚠️ **恢复不会带回上传的图片与字体**(它们在 `uploads\` 里是文件,不在 `.db` 内);需要一起回退时见 8.6。 @@ -496,7 +498,7 @@ http://<主机IP>:19540/display - `uploads/`:**全部上传资源**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo,以及**上传的字体**(放在 `uploads/fonts/` 下,见使用手册 14.3)。导入时会连文件一起落到新项目目录的 `uploads/`,切换过去后图片直接显示、字体直接可用,不用重新上传; - `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。 -导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**全部(单页)**(所有成绩平铺在一张表)、**分项成绩表**(按小项)、**单位积分明细**(按单位)各自可单独导出打印,另有可筛选(小项 / 组别 / 单位 / 批次)的**报名名单**;「积分榜」页三个榜单的卡片头还各有明细导出(**导出总明细** / **导出奖牌明细** / **导出个人明细**,见使用手册 8.3)。基础数据(单位 / 大项 / 小项 / 组别)另有单独的基础数据包可导出 / 导入,见 8.7。 +导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 单位奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**全部(单页)**(所有成绩平铺在一张表)、**分项成绩表**(按小项)、**单位积分明细**(按单位)各自可单独导出打印,另有可筛选(小项 / 组别 / 单位 / 批次)的**报名名单**;「积分榜」页三个榜单的卡片头还各有明细导出(**导出总明细** / **导出奖牌明细** / **导出个人明细**,见使用手册 8.3)。基础数据(单位 / 大项 / 小项 / 组别)另有单独的基础数据包可导出 / 导入,见 8.7。 导入与切换: @@ -510,6 +512,7 @@ http://<主机IP>:19540/display - 不能覆盖**正在运行**的赛事项目:对当前赛事的同名包,导入会被直接拒绝(先切到别的赛事再导); - 勾选「覆盖同名项目」时,旧目录会先改名保留、新项目落盘成功后才删除它; - 参赛单位 logo、会标、背景图与上传的字体都在 `uploads\` 里,所以**备份 / 迁移时要连 `event_projs\<项目名>\uploads\` 一起拷**(见 8.6); +- **赛事包上传上限是 512MB**(名单 CSV 与基础数据包那一档是 32MB,这里刻意放宽):赛事包是唯一带二进制资源的包——上传字体单个就允许 20MB(且可以传多个),再加上背景图、会标与各单位 logo,「两个字体 + 一张背景图」的正常包很容易超过 32MB,按 32MB 卡会让正常包导不进来。真的超过 512MB 时导入会被拒绝并提示「解析上传失败」,先到「系统设置 → 赛事信息」删掉用不到的图片 / 字体,再重新导出一份包; - **不要用 `-project` 启动参数来切赛事**:显式传 `-project` 时,服务端会顺手把它写回「上次打开的赛事」记忆文件,于是以后每次启动都优先打开这个项目——在页面里切完、重启后又被拉回去。只有在「刻意把一台机器固定成只开某一个项目」时才这么用:在 Console 的用户配置 `config.json` 里给 `server.args` 加 `"-project","<项目名>"` 后重启 Console,或直接 `scoreboard.exe -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -project <项目名> -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 启动(见 6.2)。 ### 8.6 手工备份 / 迁移整个数据目录 @@ -539,7 +542,7 @@ http://<主机IP>:19540/display - **列不在表里 = 不动该字段**(**布尔列也一样**:缺整列不等于「取默认值」,否则把「启用」列删掉再导回,会把所有停用的单位/大项静默改成启用);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写); - **枚举列例外:列在表里但格子空 = 不改动**(不是「取默认值」)。小项的类型 / 结果模式 / 结果类型 / 比较 / 状态 / 平均口径,以及组别的性别,留空都沿用原值;只有**新建的行**才用默认值(新建组别时性别留空 = 不限)。这是为了防手滑——误清一格不会把已发布小项打回「待录入」、也不会把「团体」改成「个人」; - 表头行在第 4 行(系统导出件,上面是标题与副标题)或第 1 行(手工整理的表)都能识别;枚举列要填英文规范值(如 `individual` / `time` / `published`),布尔列填「是 / 否」; -- 被「彻底删除」的单位不在导出件里,导入也**不会**把它翻出来(仍是隐藏状态); +- 被「彻底删除」的单位不在导出件里,导入也**不会**把它翻出来:命中它的编码那一行会**整行跳过**(不更新它的任何字段),并在导入结果里报 `单位 第 N 行:单位 XX 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先恢复该单位`,其余合法的行照常写入。要让这个单位回来,先到「基础数据 → 参赛单位」用「恢复」把它放回列表(见使用手册 10.1); - **这个包只含基础数据**,不含成绩、报名与规则;整场赛事的搬迁仍用赛事项目包(见 8.5)。赛前导出一份留底,现场改乱了可以导回对照。 --- @@ -702,7 +705,7 @@ http://<主机IP>:19540/display - [ ] **当前赛事正确**:「导出 → 赛事项目包 → 赛事项目列表」里带「当前」徽标的是本次要用的那个项目(刚导入的包要切换过去才算生效,见 8.5) - [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常 - [ ] **显示器菜单可用**:在服务管理窗口打开的「大屏控制」页里能看到每块屏并「创建全屏窗口」,副屏被误关时能重开(见 7.5) -- [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 团体奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7) +- [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 单位奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7) - [ ] **大屏字体已选好并核对**:「系统设置 → 赛事信息 → 字体」里选了标题字体与正文字体,大屏上显示正常;上传的字体在数据目录 `event_projs\<项目名>\uploads\fonts\` 里,导出赛事项目包会一并带走(见 8.5) - [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6) - [ ] 管理端各角色账号可登录,权限正确 diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index bba60b0..b7883f8 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -52,10 +52,23 @@ const ( // touchInterval 是 last_seen_at 的回写节流窗口:没有续期时,活动时间最快每分钟写一次。 // 一次页面加载会打若干个请求,逐个回写纯属放大写压力;last_seen_at 只用于排障,分钟级足够。 touchInterval = time.Minute + // sessionPurgeInterval 是「清理过期会话行」的最小间隔。 + // sessions 只增不减(每次登录一行,过期行与 token 哈希永久留存),需要一个清理动作; + // 单实例部署不值得为它常开一个 goroutine(还要在 main 里管生命周期), + // 于是挂在「查找会话」这条登录后必经的路径上按间隔做一次:只要有人在线,清理就不会停。 + sessionPurgeInterval = time.Hour // DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。 DefaultBcryptCost = 12 // DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。 DefaultLoginPerMinute = 5 + // LoginRateKey 是系统库 settings 里存登录限流的键。0005 迁移种下 5, + // 但在本项改动之前全仓没有一行代码读它——现场按「系统设置」改这个键不起作用, + // 限流实际上只由编译期常量决定,属死配置。 + LoginRateKey = "security.login_rate_per_minute" + // MinLoginPerMinute / MaxLoginPerMinute 是系统库那个键的可接受范围。 + // 上限卡在 60:这是防爆破的闸门,配成几千等于把它关掉;越界或非法值按「未配置」回落默认。 + MinLoginPerMinute = 1 + MaxLoginPerMinute = 60 // DefaultWritePerMinute 写操作限流:120 次/分钟/用户。 // // 原来定 30,现场一次批量导入 / 批量删除跑完就会撞上(第 7 次起全是 429), @@ -90,6 +103,10 @@ type User struct { } // Session 是一次登录产生的服务端会话。 +// +// IP / UserAgent 目前除本包外无人读(面板还没有「在线会话」视图),但保留写入: +// 排查「这条会话是哪个 IP / 哪台设备开的」时它们才是唯一线索, +// 而留存本身有上界——Lookup 里的过期行清理会把超过会话寿命的行连同这两列一起删掉(见 purgeExpiredSessions)。 type Session struct { ID string UserID int64 @@ -104,8 +121,14 @@ type Options struct { // SessionTTL 显式指定登录有效期(测试 / 嵌入用)。优先级最低的那一档默认值: // 只要系统库 settings 里有有效的 security.session_hours(面板写的就是它),就以面板为准; // 这里只在系统库里没有配置时兜底(例如测试用的空库)。 - SessionTTL time.Duration - BcryptCost int + // + // 注意:生产构造点(main.go)不传这个字段,也没有对应的启动参数 / 配置文件字段—— + // devdocs/05 里「面板 > 启动参数 > 内置默认」的中间那一档目前只存在于本字段, + // 实际只有测试在用(P2-C13)。要把它变成真的启动参数得先在 main.go 加 flag。 + SessionTTL time.Duration + BcryptCost int + // LoginPerMinute 显式指定登录限流(测试 / 嵌入用);优先级同 SessionTTL: + // 系统库 settings 里的 security.login_rate_per_minute 优先,本字段只在库里没有有效值时生效。 LoginPerMinute int WritePerMinute int // Secure 为 true 时 Cookie 带 Secure 标记(HTTPS 部署)。 @@ -123,7 +146,10 @@ type Service struct { now func() time.Time // ttl 是当前生效的登录有效期;面板可以随时改(SetSessionTTL), // 所以每个请求都会读它,必须用原子量而不是普通字段。 - ttl atomic.Int64 + ttl atomic.Int64 + // lastPurge 是上一次清理过期会话行的时刻(Unix 纳秒,0 = 还没清理过)。 + // 请求会并发走到 Lookup,节流判断与「谁来做这次清理」都必须原子地一次完成。 + lastPurge atomic.Int64 loginLimiter *Limiter writeLimiter *Limiter } @@ -151,9 +177,13 @@ func New(db *sql.DB, opts Options) *Service { if now == nil { now = time.Now } - loginLimit := opts.LoginPerMinute - if loginLimit == 0 { - loginLimit = DefaultLoginPerMinute + loginLimit, loginSource := DefaultLoginPerMinute, "内置默认" + // 与登录有效期同一条优先级:系统库设置 > 启动参数 > 内置默认。 + // 0005 迁移早就种下了 security.login_rate_per_minute,但直到现在才有人读它。 + if v, ok := loginRateFromSettings(db); ok { + loginLimit, loginSource = v, "系统设置" + } else if opts.LoginPerMinute > 0 { + loginLimit, loginSource = opts.LoginPerMinute, "启动参数" } writeLimit := opts.WritePerMinute if writeLimit == 0 { @@ -161,6 +191,7 @@ func New(db *sql.DB, opts Options) *Service { } logger.Info("登录有效期", "hours", ttl.Hours(), "source", source, "滑动续期", true, "节流窗口", renewThreshold.String()) + logger.Info("登录限流", "perMinute", loginLimit, "source", loginSource) s := &Service{ db: db, log: logger, @@ -240,6 +271,31 @@ func sessionTTLFromSettings(db *sql.DB) (time.Duration, bool) { return time.Duration(EffectiveSessionHours(values)) * time.Hour, true } +// loginRateFromSettings 从系统库 settings 读登录限流(次/分钟/IP)。 +// 缺键、非法或越界一律 ok=false,由调用方回落到启动参数 / 内置默认—— +// 这个键是防爆破的闸门,配错了宁可用默认值,也不能因为库里写了个 0 就把闸门拿掉。 +func loginRateFromSettings(db *sql.DB) (int, bool) { + if db == nil { + return 0, false + } + var raw string + switch err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, LoginRateKey).Scan(&raw); { + case errors.Is(err, sql.ErrNoRows): + return 0, false + case err != nil: + slog.Warn("读取登录限流失败,本次用默认值", "err", err, "perMinute", DefaultLoginPerMinute) + return 0, false + } + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < MinLoginPerMinute || n > MaxLoginPerMinute { + slog.Warn("登录限流配置非法,本次用默认值", + "value", raw, "perMinute", DefaultLoginPerMinute, + "min", MinLoginPerMinute, "max", MaxLoginPerMinute) + return 0, false + } + return n, true +} + // AllowLogin 按 IP 限流登录。 func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) } @@ -282,6 +338,8 @@ func (s *Service) EnsureDefaultAdmin(ctx context.Context) (username, password st func RandomPassword(n int) (string, error) { return randomPassword(n) } // SetPassword 重置用户密码并作废其全部会话;mustChange 表示下次登录后强制改密。 +// +// 「改密」与「作废会话」必须同成同败,因此两条语句进同一个事务(见 withUserSessionRevoke)。 func (s *Service) SetPassword(ctx context.Context, username, password string, mustChange bool) error { if password == "" { return fmt.Errorf("密码不能为空") @@ -297,43 +355,67 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu if mustChange { must = 1 } - res, err := s.db.ExecContext(ctx, - `UPDATE users SET password_hash = ?, must_change_password = ?, updated_at = datetime('now') WHERE username = ?`, - string(hash), must, username) - if err != nil { - return fmt.Errorf("更新密码: %w", err) - } - if n, err := res.RowsAffected(); err == nil && n == 0 { - return fmt.Errorf("用户 %s 不存在", username) - } - - var id int64 - if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, username).Scan(&id); err != nil { - return fmt.Errorf("查询用户: %w", err) - } - return s.RevokeUserSessions(ctx, id) + return s.withUserSessionRevoke(ctx, username, func(tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, + `UPDATE users SET password_hash = ?, must_change_password = ?, updated_at = datetime('now') WHERE username = ?`, + string(hash), must, username) + if err != nil { + return fmt.Errorf("更新密码: %w", err) + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return fmt.Errorf("用户 %s 不存在", username) + } + return nil + }) } // SetUsername 修改用户名(供本机管理入口免登录调用),并作废该用户全部会话。 +// 与 SetPassword 同一口径:改名与撤会话要么都成、要么都不成。 func (s *Service) SetUsername(ctx context.Context, oldUsername, newUsername string) error { if newUsername == "" { return fmt.Errorf("新用户名不能为空") } - res, err := s.db.ExecContext(ctx, - `UPDATE users SET username = ?, updated_at = datetime('now') WHERE username = ?`, - newUsername, oldUsername) - if err != nil { - return fmt.Errorf("更新用户名: %w", err) - } - if n, err := res.RowsAffected(); err == nil && n == 0 { - return fmt.Errorf("用户 %s 不存在", oldUsername) - } + return s.withUserSessionRevoke(ctx, newUsername, func(tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, + `UPDATE users SET username = ?, updated_at = datetime('now') WHERE username = ?`, + newUsername, oldUsername) + if err != nil { + return fmt.Errorf("更新用户名: %w", err) + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return fmt.Errorf("用户 %s 不存在", oldUsername) + } + return nil + }) +} - var id int64 - if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE username = ?`, newUsername).Scan(&id); err != nil { - return fmt.Errorf("查询用户: %w", err) +// withUserSessionRevoke 在一个事务里执行 fn(改用户行)并删掉该用户(改名后用 newName 定位)的全部会话行。 +// +// 为什么要事务:改密 / 改名与「作废会话」是同一个安全动作的两半,中间态是危险的—— +// 撤会话失败而密码已改 = 「密码改了,但旧会话(可能正是被盗用的那个)仍然全有效」, +// 用户以为已经止血、实际没有;反过来先撤会话再改密失败 = 人全被踢下线却没改成密码。 +// 事务保证外部只看得到两种结果:全做了,或什么都没做(此时旧密码与旧会话都还在,语义自洽)。 +// +// system.db 的连接池上限是 1(store.Open),事务在提交前独占该连接,两条语句不会被别的请求插进来。 +func (s *Service) withUserSessionRevoke(ctx context.Context, username string, fn func(*sql.Tx) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("开启事务: %w", err) } - return s.RevokeUserSessions(ctx, id) + defer func() { _ = tx.Rollback() }() + + if err := fn(tx); err != nil { + return err + } + // 用子查询定位 user_id:用户名唯一,省掉一次「读回 id」的往返(读了也是同一事务内的快照)。 + if _, err := tx.ExecContext(ctx, + `DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE username = ?)`, username); err != nil { + return fmt.Errorf("作废会话: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务: %w", err) + } + return nil } // VerifyPassword 校验指定用户的原密码,不创建会话(供「修改密码」等场景使用)。 @@ -436,6 +518,10 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er sess.ExpiresAt = exp u.Role = ParseRole(role) + // 过期会话行顺手清一次(节流见 sessionPurgeInterval)。放在续期之前: + // 续期那条分支会提前 return,放后面就永远轮不到它。 + s.purgeExpiredSessions(ctx, now) + // 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期, // 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。 // @@ -444,13 +530,22 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er // 少顺延这 1 分钟不会让任何人提前掉线(前一次续期至少还剩一个有效期)。 if ttl := s.SessionTTL(); ttl > renewThreshold && exp.Sub(now) < ttl-renewThreshold { renewed := now.Add(ttl) - _, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`, - now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID) + if _, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`, + now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID); err != nil { + // 写库失败时不能只顺延内存里的到期时间:/api/auth/me 会拿 sess.ExpiresAt 重发 Cookie, + // 于是浏览器被告知「还有一整天」,库里却还是旧时刻——用户会在毫无征兆的情况下被登出, + // 排查时「服务端说没过期、库里已经过期」两边对不上。 + // 这里的取舍是「放弃本次续期」:本次请求照常放行(会话此刻仍然有效), + // 库与内存保持同一个到期时间,等下一次请求再试。 + s.log.Warn("会话续期写库失败,本次不续期", "err", err, "sessionId", sess.ID) + return &u, &sess, nil + } sess.ExpiresAt = renewed return &u, &sess, nil } // 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。 // 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。 + // 这条写失败无所谓:last_seen_at 只用于排障,不影响「会话还活着」的判定。 lastSeen, err := parseDBTime(lastSeenRaw) if err != nil || now.Sub(lastSeen) >= touchInterval { _, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, @@ -459,6 +554,34 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er return &u, &sess, nil } +// purgeExpiredSessions 删掉已过期的会话行。 +// +// sessions 只增不减:每次登录留一行,过期行连同 token 哈希永久躺在库里(含 ip / user_agent), +// 这是无界增长也是个人信息留存。迁移里早就有 idx_sessions_expires_at 索引,就是为这件事准备的。 +// 节流到 sessionPurgeInterval 一次,且只在「有人带着会话访问」时触发—— +// 没人登录就没有新会话行,也就不需要清理。 +// 失败不吞:写 WARN,并把节流时间戳清掉,让下一次请求立刻重试。 +func (s *Service) purgeExpiredSessions(ctx context.Context, now time.Time) { + last := s.lastPurge.Load() + if last != 0 && now.Sub(time.Unix(0, last)) < sessionPurgeInterval { + return + } + // CAS 只有一个请求能拿到「本次清理」的资格,其余请求直接跳过(并发下只删一次)。 + if !s.lastPurge.CompareAndSwap(last, now.UnixNano()) { + return + } + res, err := s.db.ExecContext(ctx, + `DELETE FROM sessions WHERE expires_at <= ?`, now.UTC().Format(time.DateTime)) + if err != nil { + s.lastPurge.Store(0) + s.log.Warn("清理过期会话失败", "err", err) + return + } + if n, err := res.RowsAffected(); err == nil && n > 0 { + s.log.Info("清理过期会话", "rows", n) + } +} + // Logout 删除会话(服务端强制下线)。 func (s *Service) Logout(ctx context.Context, token string) error { if strings.TrimSpace(token) == "" { diff --git a/server/internal/auth/session_cookie_test.go b/server/internal/auth/session_cookie_test.go new file mode 100644 index 0000000..9e93e19 --- /dev/null +++ b/server/internal/auth/session_cookie_test.go @@ -0,0 +1,132 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// refreshResult 是一次 RefreshSessionCookies 写出的 Cookie(按名字索引)。 +func refreshResult(t *testing.T, svc *Service, sessionToken, csrfToken string, expiresAt time.Time) map[string]*http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + svc.RefreshSessionCookies(rec, sessionToken, csrfToken, expiresAt) + out := map[string]*http.Cookie{} + for _, c := range rec.Result().Cookies() { + out[c.Name] = c + } + return out +} + +// 滑动续期之后必须重发 Cookie,而且 Max-Age 要跟着「会话当前还剩多久」走: +// +// Max-Age 是浏览器端的硬期限。写成完整有效期(登录时那个固定数)时, +// 真出现「会话剩余寿命与有效期不一致」的时刻(有效期被改短、会话是旧口径建的), +// 浏览器就会在服务端仍然认这条会话的时候把 Cookie 丢掉 —— 用户毫无征兆地被登出, +// 而服务端日志上看这条会话一直是活的。这条之前的 0 覆盖正是这个承诺没人守。 +func TestRefreshSessionCookiesMaxAgeFollowsRemainingLife(t *testing.T) { + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + db := newSettingsDB(t, nil) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, + Now: func() time.Time { return now }}) + + // ① 续期恰好到「now + 24h」:Max-Age 是 24h 的秒数。 + got := refreshResult(t, svc, "sess-token", "csrf-token", now.Add(24*time.Hour)) + sess, csrf := got[CookieSession], got[CookieCSRF] + if sess == nil || csrf == nil { + t.Fatalf("续期必须同时重发会话与 CSRF Cookie,得到 %v", got) + } + if sess.Value != "sess-token" || csrf.Value != "csrf-token" { + t.Fatalf("Cookie 值必须沿用原值(CSRF 不轮换,避免和飞行中的写请求打架),得到 %q / %q", + sess.Value, csrf.Value) + } + if want := 24 * 60 * 60; sess.MaxAge != want || csrf.MaxAge != want { + t.Fatalf("Max-Age 应为剩余寿命 %d 秒,得到会话 %d / CSRF %d", want, sess.MaxAge, csrf.MaxAge) + } + + // ② 关键对照:会话只剩 30 分钟时,Max-Age 必须是 1800,而不是有效期的 86400。 + got = refreshResult(t, svc, "sess-token", "csrf-token", now.Add(30*time.Minute)) + if got[CookieSession] == nil || got[CookieSession].MaxAge != 30*60 { + t.Fatalf("Max-Age 应跟会话的剩余寿命(1800),得到 %+v", got[CookieSession]) + } + if got[CookieSession].MaxAge == 24*60*60 { + t.Fatalf("Max-Age 被写成了完整有效期:浏览器会比服务端早 23.5 小时丢掉 Cookie") + } + + // ③ 到期时间已过:一个 Cookie 都不许写(不给已经失效的会话续期限)。 + if got = refreshResult(t, svc, "sess-token", "csrf-token", now.Add(-time.Second)); len(got) != 0 { + t.Fatalf("已过期的会话不该重发任何 Cookie,得到 %v", got) + } + + // ④ 没有会话 token 时不写;CSRF 空串时只写会话(不写一枚空值 Cookie 把票面清掉)。 + if got = refreshResult(t, svc, "", "csrf-token", now.Add(time.Hour)); len(got) != 0 { + t.Fatalf("没有会话 token 时不该写任何 Cookie,得到 %v", got) + } + got = refreshResult(t, svc, "sess-token", "", now.Add(time.Hour)) + if got[CookieSession] == nil || got[CookieCSRF] != nil { + t.Fatalf("CSRF token 为空时只该重发会话 Cookie,得到 %v", got) + } + + // ⑤ 安全属性口径不变:会话 HttpOnly + SameSite=Lax + Path=/,CSRF 可被前端读取。 + if !sess.HttpOnly || sess.SameSite != http.SameSiteLaxMode || sess.Path != "/" { + t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax + Path=/:%+v", sess) + } + if csrf.HttpOnly { + t.Fatalf("CSRF Cookie 必须可被前端读取(非 HttpOnly):%+v", csrf) + } +} + +// Secure 开关(HTTPS 部署)必须作用到重发的两枚 Cookie 上,否则续期一次就退回明文传输。 +func TestRefreshSessionCookiesKeepsSecureFlag(t *testing.T) { + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + db := newSettingsDB(t, nil) + insecure := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Now: func() time.Time { return now }}) + secure := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, Secure: true, + Now: func() time.Time { return now }}) + + for _, tc := range []struct { + name string + svc *Service + secure bool + }{ + {"默认(HTTP 部署)", insecure, false}, + {"Secure=true(HTTPS 部署)", secure, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got := refreshResult(t, tc.svc, "sess-token", "csrf-token", now.Add(time.Hour)) + for _, name := range []string{CookieSession, CookieCSRF} { + if got[name] == nil { + t.Fatalf("应重发 %s,得到 %v", name, got) + } + if got[name].Secure != tc.secure { + t.Fatalf("%s 的 Secure 应为 %v,得到 %+v", name, tc.secure, got[name]) + } + } + }) + } +} + +// 停用用户是「立即失效」的第二道闸:管理端撤会话那一步万一没跑到(异常分支、手工改库), +// 会话行还在时 Lookup 也必须当它不存在——否则被停用的人只要不关页面就能继续写。 +func TestLookupRefusesInactiveUserWithLiveSession(t *testing.T) { + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + db := newSettingsDB(t, nil) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSession(t, db, userID, now.Add(24*time.Hour)) + + // 会话行还在(到期时间也没动),只把用户停用。 + execTest(t, db, `UPDATE users SET active = 0 WHERE id = ?`, userID) + + if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound { + t.Fatalf("停用用户的会话应查不到(ErrSessionNotFound),得到 %v", err) + } + // 对照:重新启用后同一条会话又能用了,证明上面拦的确实是「停用」而不是别的什么。 + execTest(t, db, `UPDATE users SET active = 1 WHERE id = ?`, userID) + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("重新启用后同一条会话应恢复可用,得到 %v", err) + } +} diff --git a/server/internal/auth/session_integrity_test.go b/server/internal/auth/session_integrity_test.go new file mode 100644 index 0000000..2b9e732 --- /dev/null +++ b/server/internal/auth/session_integrity_test.go @@ -0,0 +1,323 @@ +package auth + +import ( + "context" + "database/sql" + "sync" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// ---------- 测试脚手架 ---------- + +// blockSessionExpiryUpdate 装一个只拦 expires_at 回写的触发器,模拟「续期那句 UPDATE 失败」 +// (库被锁 / 磁盘满 / 只读挂载)。last_seen_at 的单列回写不受影响—— +// 这两条路径必须能被区分开,否则测不出 A35 的分支。 +func blockSessionExpiryUpdate(t *testing.T, db *sql.DB) { + t.Helper() + execTest(t, db, `CREATE TRIGGER block_renew BEFORE UPDATE OF expires_at ON sessions + BEGIN SELECT RAISE(ABORT, '续期写库被拦下'); END`) +} + +// blockSessionDelete 装一个拦 DELETE 的触发器,模拟「作废会话失败」。 +func blockSessionDelete(t *testing.T, db *sql.DB) { + t.Helper() + execTest(t, db, `CREATE TRIGGER block_revoke BEFORE DELETE ON sessions + BEGIN SELECT RAISE(ABORT, '撤会话被拦下'); END`) +} + +func insertSessionRow(t *testing.T, db *sql.DB, id string, userID int64, expiresAt time.Time) string { + t.Helper() + token := "token-" + id + if _, err := db.Exec( + `INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`, + id, userID, hashToken(token), expiresAt.UTC().Format(time.DateTime), expiresAt.UTC().Format(time.DateTime)); err != nil { + t.Fatalf("建会话 %s: %v", id, err) + } + return token +} + +func countSessions(t *testing.T, db *sql.DB) int { + t.Helper() + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&n); err != nil { + t.Fatalf("统计会话: %v", err) + } + return n +} + +func dbPasswordHash(t *testing.T, db *sql.DB, username string) string { + t.Helper() + var hash string + if err := db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, username).Scan(&hash); err != nil { + t.Fatalf("读密码哈希: %v", err) + } + return hash +} + +func newAuthTestService(t *testing.T, db *sql.DB, opts Options) *Service { + t.Helper() + opts.Logger = quietLogger() + if opts.BcryptCost == 0 { + opts.BcryptCost = bcrypt.MinCost + } + return New(db, opts) +} + +// ---------- A35:滑动续期写库失败 ---------- + +// 续期写库失败时不能只顺延内存里的到期时间:库里还是旧到期时间,而 /auth/me 会拿内存里那个值 +// 重发 Cookie(浏览器端的 Max-Age 跟着顺延),于是「服务端说还有 24 小时、其实早已过期」, +// 用户会在毫无征兆的情况下被登出,排查时两边还对不上。 +func TestLookupKeepsStoredExpiryWhenRenewFails(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := newAuthTestService(t, db, Options{SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + // 剩余 23h58m:已越过续期节流窗口,本次查找本该顺延到 now+24h。 + token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute)) + stored := dbExpiry(t, db) + blockSessionExpiryUpdate(t, db) + + _, sess, err := svc.Lookup(context.Background(), token) + if err != nil { + t.Fatalf("续期写库失败不该让查找失败(会话此刻仍然有效): %v", err) + } + if want := now.Add(24*time.Hour - 2*time.Minute); !sess.ExpiresAt.Equal(want) { + t.Fatalf("续期失败时必须保持库里的到期时间 %s,得到 %s", want, sess.ExpiresAt) + } + if got := dbExpiry(t, db); got != stored { + t.Fatalf("续期失败不该改动库里的到期时间:%s → %s", stored, got) + } + + // 去掉触发器后重来:续期该恢复成正常顺延(证明确实是「失败才不续」而不是「永远不续」)。 + execTest(t, db, `DROP TRIGGER block_renew`) + if _, sess, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { + t.Fatalf("写库恢复后应顺延到 %s,得到 %s", want, sess.ExpiresAt) + } +} + +// ---------- A36:改密与作废会话同成同败 ---------- + +// 正常路径:密码换掉的同时会话被清空(两个动作在一个事务里)。 +func TestSetPasswordRevokesSessionsSeenAsOneAction(t *testing.T) { + db := newSettingsDB(t, nil) + svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) + userID := insertUser(t, db, "admin") + if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil { + t.Fatalf("初始化密码: %v", err) + } + insertSession(t, db, userID, time.Now().Add(time.Hour)) + + if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err != nil { + t.Fatalf("改密应成功: %v", err) + } + if err := svc.VerifyPassword(context.Background(), userID, "new-password"); err != nil { + t.Fatalf("新密码应能通过校验: %v", err) + } + if err := svc.VerifyPassword(context.Background(), userID, "old-password"); err == nil { + t.Fatal("旧密码不该还能通过校验") + } + if n := countSessions(t, db); n != 0 { + t.Fatalf("改密后旧会话必须全部失效,库里还剩 %d 行", n) + } +} + +// 失败路径(本条的要点):撤会话失败时整件事必须回滚, +// 绝不能留下「密码已改、旧会话仍全部有效」这种用户以为已经止血、实际没有的中间态。 +func TestSetPasswordRollsBackWhenRevokeFails(t *testing.T) { + db := newSettingsDB(t, nil) + svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) + userID := insertUser(t, db, "admin") + if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil { + t.Fatalf("初始化密码: %v", err) + } + insertSession(t, db, userID, time.Now().Add(time.Hour)) + before := dbPasswordHash(t, db, "admin") + + blockSessionDelete(t, db) + if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err == nil { + t.Fatal("撤会话失败时改密必须整体失败") + } + + if got := dbPasswordHash(t, db, "admin"); got != before { + t.Fatalf("事务失败后密码哈希必须原样(回滚),%s → %s", before, got) + } + if n := countSessions(t, db); n != 1 { + t.Fatalf("事务失败后旧会话必须仍然存在(与「什么都没变」自洽),库里 %d 行", n) + } + // 外部看到的就是「什么都没变」:旧密码仍然有效、旧会话仍然可用。 + if err := svc.VerifyPassword(context.Background(), userID, "old-password"); err != nil { + t.Fatalf("回滚后旧密码应仍然有效: %v", err) + } + if err := svc.VerifyPassword(context.Background(), userID, "new-password"); err == nil { + t.Fatal("回滚后新密码不该生效") + } +} + +// 并发路径:改密期间并发的会话查找只会看到「旧世界」或「新世界」, +// 改密返回后旧 token 必须彻底查不到(不会出现「密码换了、会话还在」的残留)。 +func TestSetPasswordConcurrentLookupsLeaveNoStaleSession(t *testing.T) { + db := newSettingsDB(t, nil) + svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) + userID := insertUser(t, db, "admin") + if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil { + t.Fatalf("初始化密码: %v", err) + } + token := insertSession(t, db, userID, time.Now().Add(time.Hour)) + + stop := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + // 结果不校验:并发查找允许看到事务提交前的任意中间时刻。 + _, _, _ = svc.Lookup(context.Background(), token) + } + } + }() + } + + if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err != nil { + t.Fatalf("改密应成功: %v", err) + } + close(stop) + wg.Wait() + + // 改密返回之后,任何一条旧会话都不该还能用。 + if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound { + t.Fatalf("改密后旧会话应查不到,得到 %v", err) + } + if n := countSessions(t, db); n != 0 { + t.Fatalf("改密后库里不该还剩会话行,得到 %d", n) + } +} + +// 改名走同一条事务路径:改名成功则会话一并作废。 +func TestSetUsernameRevokesSessionsInSameTransaction(t *testing.T) { + db := newSettingsDB(t, nil) + svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) + userID := insertUser(t, db, "admin") + insertSession(t, db, userID, time.Now().Add(time.Hour)) + + if err := svc.SetUsername(context.Background(), "admin", "boss"); err != nil { + t.Fatalf("改名应成功: %v", err) + } + if n := countSessions(t, db); n != 0 { + t.Fatalf("改名后旧会话必须失效,库里还剩 %d 行", n) + } + // 再给他一条会话,才能让「撤会话」这一步真的去删行(DELETE 不命中行时不会触发触发器)。 + insertSession(t, db, userID, time.Now().Add(time.Hour)) + blockSessionDelete(t, db) + if err := svc.SetUsername(context.Background(), "boss", "chief"); err == nil { + t.Fatal("撤会话失败时改名必须整体失败") + } + var name string + if err := db.QueryRow(`SELECT username FROM users`).Scan(&name); err != nil { + t.Fatalf("读用户名: %v", err) + } + if name != "boss" { + t.Fatalf("事务失败后用户名必须回滚成 boss,得到 %q", name) + } +} + +// ---------- D01:过期会话行清理 ---------- + +// sessions 只增不减(每次登录一行,过期行与 token 哈希永久留存), +// 挂在「查找会话」这条必经路径上按 sessionPurgeInterval 节流清理。 +func TestLookupPurgesExpiredSessions(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := newAuthTestService(t, db, Options{SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSessionRow(t, db, "live", userID, now.Add(24*time.Hour)) + insertSessionRow(t, db, "old-1", userID, now.Add(-time.Hour)) + insertSessionRow(t, db, "old-2", userID, now.Add(-48*time.Hour)) + + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } + if n := countSessions(t, db); n != 1 { + t.Fatalf("过期会话行应被清掉,库里还剩 %d 行", n) + } + + // 节流:同一小时内不再清第二次(每次请求都扫一遍过期行纯属白写库)。 + insertSessionRow(t, db, "old-3", userID, now.Add(-time.Minute)) + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } + if n := countSessions(t, db); n != 2 { + t.Fatalf("节流窗口内不该再清一次,库里 %d 行", n) + } + + // 越过节流窗口后继续清理,而且不能误删仍然有效的会话。 + now = now.Add(sessionPurgeInterval + time.Minute) + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } + if n := countSessions(t, db); n != 1 { + t.Fatalf("越过节流窗口后应清掉过期行、只留有效会话,库里 %d 行", n) + } +} + +// ---------- C15:登录限流不再是死配置 ---------- + +// 迁移 0005 种下的 security.login_rate_per_minute 此前没有任何代码读它, +// 现场改这个键不起作用。现在它真的生效(越界 / 非法一律回落到内置默认)。 +func TestLoginRateFromSettings(t *testing.T) { + for _, tc := range []struct { + name string + value string + want int + }{ + {"系统库设置生效", "2", 2}, + {"上界 60 生效", "60", 60}, + {"0 非法回默认", "0", DefaultLoginPerMinute}, + {"负数非法回默认", "-1", DefaultLoginPerMinute}, + {"超上限非法回默认", "61", DefaultLoginPerMinute}, + {"非数字非法回默认", "五次", DefaultLoginPerMinute}, + } { + t.Run(tc.name, func(t *testing.T) { + db := newSettingsDB(t, map[string]string{LoginRateKey: tc.value}) + svc := newAuthTestService(t, db, Options{}) + if got := svc.loginLimiter.limit; got != tc.want { + t.Fatalf("登录限流应为 %d,得到 %d", tc.want, got) + } + // 行为断言,不只看字段:第 want+1 次必须被拦下。 + for i := 0; i < tc.want; i++ { + if !svc.AllowLogin("1.2.3.4") { + t.Fatalf("第 %d 次应放行", i+1) + } + } + if svc.AllowLogin("1.2.3.4") { + t.Fatalf("第 %d 次应被限流", tc.want+1) + } + }) + } + + t.Run("系统库没有键时用启动参数兜底", func(t *testing.T) { + db := newSettingsDB(t, nil) + svc := newAuthTestService(t, db, Options{LoginPerMinute: 3}) + if got := svc.loginLimiter.limit; got != 3 { + t.Fatalf("应回落到启动参数的 3,得到 %d", got) + } + }) + + t.Run("系统库有键时优先于启动参数", func(t *testing.T) { + db := newSettingsDB(t, map[string]string{LoginRateKey: "2"}) + svc := newAuthTestService(t, db, Options{LoginPerMinute: 9}) + if got := svc.loginLimiter.limit; got != 2 { + t.Fatalf("系统库设置应优先,期望 2,得到 %d", got) + } + }) +} diff --git a/server/internal/domain/errors.go b/server/internal/domain/errors.go index dd9f5ca..1acf510 100644 --- a/server/internal/domain/errors.go +++ b/server/internal/domain/errors.go @@ -4,16 +4,24 @@ import "errors" // 引擎的哨兵错误:调用方(store / httpapi)据此区分「配置错」与「数据错」。 var ( - ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在") - ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis") - ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc") + ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在") + ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis") + ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc") + + // 下面这 5 个是「无返回方」的哨兵:全仓(含 _test.go)只有 + // internal/httpapi/response.go 的错误映射分支在引用它们,本包没有任何一行会返回。 + // 来源是已经删掉的流水作废 / 增量接口(人工调整现在走 httpapi 自己的 + // stateInvalid / badRequest),映射分支实际上是悬空的。 + // 它们留在 domain 里的唯一理由是让那份映射仍能编译;新增功能时不要照着它们造错误, + // 真要用先接线返回方。删掉需同步删映射(response.go)。 ErrLedgerNotFound = errors.New("domain: 积分流水不存在") ErrLedgerAlreadyVoided = errors.New("domain: 积分流水已作废,不能重复作废") - ErrDuplicateLedgerID = errors.New("domain: 积分流水 id 重复") ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用") ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由") ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作") + // ErrPointsOverrideUnsupported:成绩上的 pointsOverride 不被引擎读取。 // 与其静默忽略(设置成功但毫无效果),不如显式报错;加减分请走人工积分调整。 + // 这条有真正的返回方(scoring.go 的 ScoreEvent),与上面那组不同。 ErrPointsOverrideUnsupported = errors.New("domain: 成绩不接受 pointsOverride,请改用人工积分调整") ) diff --git a/server/internal/domain/matrix_test.go b/server/internal/domain/matrix_test.go index fb05426..6e41f43 100644 --- a/server/internal/domain/matrix_test.go +++ b/server/internal/domain/matrix_test.go @@ -2,7 +2,8 @@ package domain import "testing" -// 本文件逐条实现 devdocs/09-测试策略.md 第 2 节的 18 条用例矩阵。 +// 本文件实现 devdocs/09-测试策略.md 第 2 节 17 条用例矩阵中的 13 条(01–07、10–15); +// 第 8 / 9 / 17 / 18 条没有同名测试函数,第 16 条(联队)已整条移除。 // 用例名带编号,go test -v 一眼能看出哪条矩阵用例通过。 // case 1:8 人参赛正常排名 → 9/7/6/5/4/3/2/1。 diff --git a/server/internal/domain/medal_channels_test.go b/server/internal/domain/medal_channels_test.go index d256bb5..b697504 100644 --- a/server/internal/domain/medal_channels_test.go +++ b/server/internal/domain/medal_channels_test.go @@ -161,7 +161,7 @@ func TestExcludeTeamEventMedalsStillApplies(t *testing.T) { team, _ = medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked, ExcludeTeamEventMedals: true}) if team != [3]int{0, 0, 0} { - t.Fatalf("全局排除团体奖牌后单位榜 = %v,期望 0", team) + t.Fatalf("全局排除团体项目奖牌后单位榜 = %v,期望 0", team) } } diff --git a/server/internal/domain/scoring_boundary_test.go b/server/internal/domain/scoring_boundary_test.go new file mode 100644 index 0000000..2ad4b4c --- /dev/null +++ b/server/internal/domain/scoring_boundary_test.go @@ -0,0 +1,211 @@ +package domain + +import "testing" + +// C04:avg_mode 的分母在「报名项退赛 / 失格」与「成绩失格 / 没录」混在一起时的口径。 +// +// scoring.go 的 scoreByAggregate 定下的口径是: +// - 报名项非 registered(scratched / dq / dns):整条不进桶,既不算分子也不算分母、不占名次; +// - 报名项正常但成绩失格 / 没录:进 all 的分母(按 0 计入),不进 valid 的分子与分母。 +// +// 此前只有「有成绩 / 没成绩 / 没录成绩」的对照(TestAggregateAvgMode), +// 退赛与失格混进来时分母是谁没人钉过——把退赛者算进分母会让全队平均被拉低、名次互换, +// 而现场看到的只是榜单数字莫名其妙变了。 +func TestAggregateAvgModeDenominatorWithScratchedAndDQ(t *testing.T) { + build := func() ([]Entry, []Result) { + return fixture([]participant{ + // 1001:一条正常成绩 10;一条报名项退赛(带着 6,必须整条被忽略); + // 一条报名项正常但成绩失格(all 口径要按 0 计入分母)。 + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 6, entryStatus: EntryStatusScratched}, + {entryID: 3, athleteID: 3, teamID: 1001, value: 4, status: ResultStatusDQ}, + // 1002:一条正常成绩 8;一条报名项失格且成绩是 100(若被算进分母或分子, + // 平均会被拉成 54,名次当场互换——这是本条最容易写错的地方)。 + {entryID: 4, athleteID: 4, teamID: 1002, value: 8}, + {entryID: 5, athleteID: 5, teamID: 1002, value: 100, entryStatus: EntryStatusDQ}, + }) + } + + for _, tc := range []struct { + name string + mode AvgMode + want map[int64]aggregateOutcome + }{ + { + // 分子分母都只算「报名项正常且成绩有效」的人:1001 → 10/1,1002 → 8/1。 + // compare=asc(越小越好)→ 1002 第一(15 分),1001 第二(10 分)。 + name: "valid:退赛与失格的报名项都不算分子分母", + mode: AvgModeValid, + want: map[int64]aggregateOutcome{ + 1001: {value: 10, points: 10, rank: 2}, + 1002: {value: 8, points: 15, rank: 1}, + }, + }, + { + // 分母 = 报名项数:1001 有 2 条正常报名项(10 + 0)/ 2 = 5;1002 只有 1 条 → 8。 + // 退赛的 6 与失格的 100 都不该出现在任何一边。 + name: "all:分母是报名项数,退赛者连桶都不进", + mode: AvgModeAll, + want: map[int64]aggregateOutcome{ + 1001: {value: 5, points: 15, rank: 1}, + 1002: {value: 8, points: 10, rank: 2}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + entries, results := build() + ev := teamEvent(1) + ev.AvgMode = tc.mode + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{teamAggregateChannel(2)}, + Profiles: map[int64]ScoringProfile{2: teamTop6()}, + Entries: entries, Results: results, + }) + + got := aggregateByTeam(t, es) + for teamID, want := range tc.want { + have, ok := got[teamID] + if !ok { + t.Fatalf("单位 %d 应参与排名,实际 %+v", teamID, got) + } + if !nearlyEqual(have.value, want.value) { + t.Errorf("单位 %d 平均成绩 = %v,期望 %v(分子分母口径不符)", teamID, have.value, want.value) + } + if have.rank != want.rank || !nearlyEqual(have.points, want.points) { + t.Errorf("单位 %d = 第 %d 名 %v 分,期望 第 %d 名 %v 分", + teamID, have.rank, have.points, want.rank, want.points) + } + } + if len(got) != len(tc.want) { + t.Errorf("参与排名的单位数 = %d,期望 %d(%+v)", len(got), len(tc.want), got) + } + // 流水与名次同源:每个单位的积分都能在流水里找到。 + ledger := pointsByTeam(es.Ledger) + for teamID, want := range tc.want { + if !nearlyEqual(ledger[teamID], want.points) { + t.Errorf("单位 %d 流水 = %v,期望 %v", teamID, ledger[teamID], want.points) + } + } + }) + } + + t.Run("整队报名项全退赛:不成桶、不上榜、不得分", func(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 1, entryStatus: EntryStatusScratched}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 2, entryStatus: EntryStatusDQ}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 8}, + }) + for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} { + ev := teamEvent(1) + ev.AvgMode = mode + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{teamAggregateChannel(2)}, + Profiles: map[int64]ScoringProfile{2: teamTop6()}, + Entries: entries, Results: results, + }) + if got := aggregateByTeam(t, es); got[1001].value != 0 || got[1001].points != 0 || got[1001].rank != 0 { + t.Errorf("%s:全退赛的单位不该上榜、不该得分,得到 %+v", mode, got[1001]) + } + if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1002], 15) || got[1001] != 0 { + t.Errorf("%s:只剩 1002 时应独占第一(15 分),得到 %v", mode, got) + } + } + }) +} + +// aggregateOutcome 是一次聚合通道计分后「某个单位拿到的平均成绩 / 名次 / 积分」。 +type aggregateOutcome struct { + value float64 + rank int + points float64 +} + +func aggregateByTeam(t *testing.T, es EventScore) map[int64]aggregateOutcome { + t.Helper() + out := make(map[int64]aggregateOutcome, len(es.Ranked)) + for _, rr := range es.Ranked { + if rr.ResultValue == nil { + t.Fatalf("聚合通道的名次明细应带上平均成绩:%+v", rr) + } + out[rr.TeamID] = aggregateOutcome{value: *rr.ResultValue, rank: rr.Rank, points: rr.Points} + } + return out +} + +// C04:并列组「跨越录取名次边界」时分怎么算。 +// +// 一组并列者占用的是 startRank .. startRank+size-1 这一整段名次,这段区间跨过 AwardRanks 时 +// 就出现了「组内一部分有名次、一部分没有」: +// +// skip 取起排名次的分(6 人表里并列第 6 的两人都拿第 6 名的分); +// average 把区间内所有名次的分平均,没配到的名次按 0(两人并列第 6 → (1+0)/2 = 0.5); +// higher 取组内最高分(并列第 6 → 1)。 +// +// 三种口径在这里给出三个不同的数,而现场只看得到分数——之前没有用例钉住这条边界。 +func TestGroupPointsTieGroupCrossingAwardRanksBoundary(t *testing.T) { + ev := individualEvent(1) + cat := testCategory() + top6 := ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules} + top3 := ScoringProfile{AwardRanks: 3, Rules: individualTop6().Rules} + + cases := []struct { + name string + profile ScoringProfile + start int + size int + want float64 + }{ + // 录取线内(占 5、6,都在前六名里):三种口径都只用到配置了的名次。 + {"两人并列第5 average", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 5, 2, 1.5}, + // 跨线(占 6、7,第 7 名没配分):average 把第 7 名当 0 平均掉。 + {"两人并列第6 average 跨过录取线", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 6, 2, 0.5}, + {"两人并列第6 higher 取组内最高", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 6, 2, 1}, + {"两人并列第6 skip 取起排位", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 6, 2, 1}, + // 三人跨两格(占 5、6、7)。 + {"三人并列第5 average 跨线", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 5, 3, 1}, + {"三人并列第5 higher", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 5, 3, 2}, + {"三人并列第6 average 只剩一格有名次", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 6, 3, 1.0 / 3.0}, + // 整组都在录取线外:三种口径都是 0,不能出现负数或异常。 + {"两人并列第7 skip 全在录取线外", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 7, 2, 0}, + {"两人并列第7 average 全在录取线外", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 7, 2, 0}, + // 一组人从第 1 名一直占到第 7 名(全场并列):average 把 0 也算进去。 + {"七人并列第1 average", ScoringProfile{AwardRanks: 6, TieMode: TieAverage, Rules: top6.Rules}, 1, 7, 30.0 / 7.0}, + {"七人并列第1 higher", ScoringProfile{AwardRanks: 6, TieMode: TieHigher, Rules: top6.Rules}, 1, 7, 10}, + {"七人并列第1 skip", ScoringProfile{AwardRanks: 6, TieMode: TieSkip, Rules: top6.Rules}, 1, 7, 10}, + // 录取名次更少时边界跟着走(top3 的线在第 3 名)。 + {"top3 两人并列第3 average", ScoringProfile{AwardRanks: 3, TieMode: TieAverage, Rules: top3.Rules}, 3, 2, 3}, + {"top3 两人并列第3 skip", ScoringProfile{AwardRanks: 3, TieMode: TieSkip, Rules: top3.Rules}, 3, 2, 6}, + {"top3 两人并列第3 higher", ScoringProfile{AwardRanks: 3, TieMode: TieHigher, Rules: top3.Rules}, 3, 2, 6}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := groupPoints(tc.profile, tc.start, tc.size, 6, ev, cat) + if !nearlyEqual(got, tc.want) { + t.Fatalf("groupPoints(start=%d, size=%d) = %v,期望 %v", tc.start, tc.size, got, tc.want) + } + }) + } + + // 端到端对照:同样的并列,走完整算分后落到名次明细上的分数必须与上面一致。 + t.Run("端到端:两人并列第6 的 average 口径", func(t *testing.T) { + entries, results := fixture(lineup(7)) + // 第 6、7 名同分(都记 6.0)→ 并列占第 6、7 名;第 7 名没有配置分。 + results[5].ResultValue = ptr(6.0) + results[6].ResultValue = ptr(6.0) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(9)}, + Profiles: map[int64]ScoringProfile{9: {AwardRanks: 6, TieMode: TieAverage, Rules: individualTop6().Rules}}, + Entries: entries, Results: results, + }) + points := pointsByEntry(es.Ledger) + if !nearlyEqual(points[6], 0.5) || !nearlyEqual(points[7], 0.5) { + t.Fatalf("并列第 6 的两人应各得 0.5 分,得到 %v / %v", points[6], points[7]) + } + if !nearlyEqual(points[5], 2) { + t.Fatalf("第 5 名应得 2 分,得到 %v", points[5]) + } + }) +} diff --git a/server/internal/domain/standings.go b/server/internal/domain/standings.go index 37cf8c2..f3c3035 100644 --- a/server/internal/domain/standings.go +++ b/server/internal/domain/standings.go @@ -30,7 +30,7 @@ type StandingsInput struct { TieBreak []string // IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。 IndividualToTeamWeight float64 - // ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。 + // ExcludeTeamEventMedals 为 true 时单位奖牌榜不计团体项目奖牌;默认计入。 ExcludeTeamEventMedals bool } @@ -95,7 +95,7 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow { return rows } -// BuildMedalStandings 生成团体奖牌榜。 +// BuildMedalStandings 生成单位奖牌榜。 // 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。 func BuildMedalStandings(in StandingsInput) []MedalRow { teamRows := BuildTeamStandings(in) diff --git a/server/internal/domain/standings_test.go b/server/internal/domain/standings_test.go index 2f3c7dd..b18c16e 100644 --- a/server/internal/domain/standings_test.go +++ b/server/internal/domain/standings_test.go @@ -142,7 +142,7 @@ func TestMedalStandings(t *testing.T) { rows = BuildMedalStandings(in) for _, r := range rows { if r.TeamID == 1 && r.Gold != 0 { - t.Fatalf("排除团体奖牌后单位1金牌 = %d,期望 0", r.Gold) + t.Fatalf("排除团体项目奖牌后单位1金牌 = %d,期望 0", r.Gold) } } } diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index 546a95b..eed0265 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -353,7 +353,7 @@ type StandingsRow struct { Medals int `json:"medals"` } -// MedalRow 团体奖牌榜的一行。 +// MedalRow 单位奖牌榜的一行。 type MedalRow struct { Rank int `json:"rank"` TeamID int64 `json:"teamId"` diff --git a/server/internal/export/athletes.go b/server/internal/export/athletes.go index 739adf9..ffb7cb2 100644 --- a/server/internal/export/athletes.go +++ b/server/internal/export/athletes.go @@ -65,6 +65,7 @@ var athleteDetailColumns = []column{ var athleteTeamColumns = athleteDetailColumns[1:] // ExportAthleteDetailXLSX 导出个人得分明细,落盘到 dst。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportAthleteDetailXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadAthleteDetailWorkbook(db, opts) if err != nil { diff --git a/server/internal/export/backup.go b/server/internal/export/backup.go index 75d8b14..1a5cd95 100644 --- a/server/internal/export/backup.go +++ b/server/internal/export/backup.go @@ -126,6 +126,10 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) { // BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish, // 便于在备份列表里一眼看出哪些是发布节点留的。 +// +// 当前没有生产调用方(代码卫生项 C29):生产走 Service.RunEvent("publish")(同一把 Service.mu +// 串行化,见 backup.go 的 Service),只有 backup_test.go 在用。留着是因为「reason=publish 的 +// 备份名能被 ListBackups/reasonFromName 认回来」这条口径需要它来钉。 func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) { if strings.TrimSpace(opts.Reason) == "" { opts.Reason = "publish" @@ -281,7 +285,11 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) { return out, nil } -// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。 +// LatestBackup 返回最近一份备份(ListBackups 已按时间倒序,取首个)。 +// +// 注意:它**不是**健康检查的输入——05 §9 明确说 GET /api/health 不返回「最近备份时间」, +// 备份列表接口走的是 ListBackups。当前只有 backup_test.go 在用它(代码卫生项 C29), +// 加调用方前先想清楚要不要真的把备份时间暴露到健康检查里。 func LatestBackup(dir, prefix string) (BackupInfo, bool, error) { list, err := ListBackups(dir, prefix) if err != nil || len(list) == 0 { diff --git a/server/internal/export/basedata.go b/server/internal/export/basedata.go index c27e8e3..a05110b 100644 --- a/server/internal/export/basedata.go +++ b/server/internal/export/basedata.go @@ -404,17 +404,23 @@ func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) { defer func() { _ = f.Close() }() sheets := f.GetSheetList() - present := make(map[string]bool, len(sheets)) + // 认表用去空白后的名字(现场整理过的表常带尾随空格),取表必须用**原名**: + // 之前拿 spec.Sheet 去 GetRows,表就在文件里却报「sheet 单位 does not exist」—— + // 一句与事实相反的错,排障时最容易把人带偏。 + actual := make(map[string]string, len(sheets)) for _, name := range sheets { - present[strings.TrimSpace(name)] = true + actual[strings.TrimSpace(name)] = name } tables := make([]BaseDataTable, 0, len(baseDataSheets)) for _, spec := range BaseDataSpecs() { - if !present[spec.Sheet] { + name, ok := actual[spec.Sheet] + if !ok { continue } - rows, err := f.GetRows(spec.Sheet) + // 报错与行级提示一律用契约里的表名(spec.Sheet),不用带空格的原名, + // 免得同一张表在错误信息里出现两种写法。 + rows, err := f.GetRows(name) if err != nil { return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err) } diff --git a/server/internal/export/basedata_test.go b/server/internal/export/basedata_test.go index de884c0..f8d0ada 100644 --- a/server/internal/export/basedata_test.go +++ b/server/internal/export/basedata_test.go @@ -258,6 +258,55 @@ func TestReadBaseDataPackageErrors(t *testing.T) { } } +// B03:工作表名带尾随空格时也必须认得出来,且取表要用文件里的原名。 +// 现场把导出件另存 / 手工改过表名后很容易多出个空格;旧代码「认表用 TrimSpace 后的名字、 +// 取表却用契约里的名字」,于是报出一句与事实相反的错:表就在文件里,却说它不存在。 +func TestReadBaseDataPackageTrimsSheetName(t *testing.T) { + const spaced = BaseDataSheetTeams + " " // "单位 ":末尾一个空格 + + f := excelize.NewFile() + defer func() { _ = f.Close() }() + if err := f.SetSheetName("Sheet1", spaced); err != nil { + t.Fatalf("重命名工作表为带尾随空格的名字: %v", err) + } + rows := [][]string{ + {"单位编码", "单位名称", "启用"}, + {"T9", "第九分公司", "是"}, + } + for r, row := range rows { + for c, v := range row { + name, err := excelize.CoordinatesToCellName(c+1, r+1) + if err != nil { + t.Fatalf("算单元格坐标: %v", err) + } + if err := f.SetCellValue(spaced, name, v); err != nil { + t.Fatalf("写单元格: %v", err) + } + } + } + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatalf("写出测试工作簿: %v", err) + } + + tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("表名带尾随空格时应照样读得出来,得到错误:%v", err) + } + if len(tables) != 1 || tables[0].Sheet != BaseDataSheetTeams { + t.Fatalf("应认出「单位」表,且 Sheet 报契约里的名字,得到 %+v", tables) + } + if len(tables[0].Rows) != 1 { + t.Fatalf("数据行数 = %d,期望 1:%+v", len(tables[0].Rows), tables[0].Rows) + } + if got := tables[0].Rows[0].Cells["code"]; got != "T9" { + t.Errorf("单位编码 = %q,期望 T9", got) + } + if got := tables[0].Rows[0].Cells["name"]; got != "第九分公司" { + t.Errorf("单位名称 = %q,期望 第九分公司", got) + } +} + func TestParseBaseDataHelpers(t *testing.T) { for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} { if v, ok := ParseBaseDataBool(raw, false); !ok || !v { diff --git a/server/internal/export/entries.go b/server/internal/export/entries.go index ee1985b..855f46a 100644 --- a/server/internal/export/entries.go +++ b/server/internal/export/entries.go @@ -67,6 +67,7 @@ var entriesColumns = []column{ } // ExportEntriesXLSX 导出报名名单到 dst。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error { wb, err := LoadEntriesWorkbook(db, filter, opts) if err != nil { diff --git a/server/internal/export/event.go b/server/internal/export/event.go index 8bb1bc9..518c138 100644 --- a/server/internal/export/event.go +++ b/server/internal/export/event.go @@ -26,6 +26,11 @@ type EventResultRow struct { // NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。 // 与 Unpublished 互斥——有成绩行才会有发布状态。 NoResult bool + // Unsettled 表示这行成绩已发布,但整组还没结算:延迟结算(分批发布)下名次与积分 + // 要等该 (小项, 组别) 录完才写,此刻行上没有名次、没有流水。 + // 它必须与「算出来就是 0 分、没名次」区分开(R18:占位符口径),见 eventRows。 + // 与 Unpublished / NoResult 互斥——那两态是「没有值」,这一态是「值有了,名次与积分还没到」。 + Unsettled bool } // EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。 @@ -60,6 +65,7 @@ var eventColumns = []column{ } // ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error { wb, err := LoadEventWorkbook(db, eventID, opts) if err != nil { @@ -130,10 +136,16 @@ const eventCellBlank = "—" // eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。 const eventCellUnpublished = "分数未录入" +// eventCellUnsettled 是「成绩已发布但整组未结算」行在状态列的文案。 +// 只把名次 / 积分写成「—」还不够:三格里的成绩有值、状态又写「有效」, +// 打印件上仍会被读成「算出来就是 0 分、没有名次」(R18 要避免的正是这个), +// 所以状态列直接写「未结算」,让人一眼看出是没结算、不是没算出来。 +const eventCellUnsettled = "未结算" + // eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。 // -// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值, -// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。 +// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入 / 未结算时都没有可比的值, +// 半空半 0 反而像填错。已发布行原样写出(这三种行都不带 r.Points 的 0)。 func eventRows(rows []EventResultRow) [][]any { out := make([][]any, 0, len(rows)) for _, r := range rows { @@ -141,6 +153,7 @@ func eventRows(rows []EventResultRow) [][]any { rank any = r.Rank result any = displayResult(r.ResultText, r.Unit) points any = r.Points + status any = r.Status ) switch { case r.NoResult: @@ -149,10 +162,14 @@ func eventRows(rows []EventResultRow) [][]any { case r.Unpublished: // 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。 rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank + case r.Unsettled: + // 已发布没结算:成绩照常印(它确实跑出来了),名次与积分等结算, + // 状态列写「未结算」把「还没到」与「就是 0 分」彻底分开。 + rank, points, status = eventCellBlank, eventCellBlank, eventCellUnsettled } out = append(out, []any{ rank, r.Bib, r.TeamName, r.Participant, - result, points, r.Status, r.Remark, + result, points, status, r.Remark, }) } return out diff --git a/server/internal/export/event_test.go b/server/internal/export/event_test.go index 6ad7bba..4519061 100644 --- a/server/internal/export/event_test.go +++ b/server/internal/export/event_test.go @@ -8,6 +8,153 @@ import ( "github.com/xuri/excelize/v2" ) +// seedUnsettledEvent 造一屏「延迟结算」现场:3 个人报名同一个组别,只有 1 个人出了成绩。 +// +// 已发布的那条**没有名次、没有流水**——延迟结算(standings.live_on_publish=false)下名次与流水 +// 要等整组录完才写(httpapi 的 scopeSettled),这正是 A17 说的「已发布未结算」。 +// 另外两条分别是「有草稿没发布」与「连成绩行都没有」,三种占位态因此能并排比。 +func seedUnsettledEvent(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 1)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 2, '102')`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`, + `INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state) + VALUES (1, 1, '12.30', 12.30, 's', 'published')`, + `INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state) + VALUES (1, 2, '12.40', 12.40, 's', 'draft')`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// A17:延迟结算下「已发布但整组未结算」的行必须写成「名次 —、积分 —、状态 未结算」, +// 不能是「名次空 + 积分 0 + 状态 有效」——打印件上后者会被读成「算出来就是 0 分」, +// 正是 R18 立项要避免的误读。分项成绩表与单页表逐格一致(两者都走 eventRows)。 +func TestUnsettledRowsPrintPlaceholders(t *testing.T) { + db := openEventDB(t) + seedUnsettledEvent(t, db) + + dir := t.TempDir() + eventDst := filepath.Join(dir, "分项.xlsx") + if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil { + t.Fatalf("导出分项成绩表: %v", err) + } + flatDst := filepath.Join(dir, "单页.xlsx") + if err := ExportFlatXLSX(db, flatDst, Options{}); err != nil { + t.Fatalf("导出单页成绩表: %v", err) + } + + // 三种占位态并排:未结算 / 草稿未发布 / 完全没录。列序见 eventColumns。 + want := [][]string{ + {"—", "101", "第一分公司", "张三", "12.30 秒", "—", "未结算", ""}, + {"—", "102", "第一分公司", "李四", "分数未录入", "—", "草稿", ""}, + {"—", "103", "第一分公司", "王五", "—", "—", "未录入", ""}, + } + + f := openXLSX(t, eventDst) + defer func() { _ = f.Close() }() + rows, err := f.GetRows("田径·100米跑·男子") + if err != nil { + t.Fatalf("读取分项成绩: %v", err) + } + for i, wantRow := range want { + for c, v := range wantRow { + if got := cell(rows, rowHeader+i, c); got != v { + t.Errorf("分项成绩表第 %d 行第 %d 列 = %q,期望 %q", i+1, c, got, v) + } + } + } + + // 单页表比多 sheet 版多三列(大项 / 小项 / 组别),后 8 列同序。 + ff := openXLSX(t, flatDst) + defer func() { _ = ff.Close() }() + flatRows, err := ff.GetRows(flatSheet) + if err != nil { + t.Fatalf("读取单页成绩: %v", err) + } + for i, wantRow := range want { + for c, v := range wantRow { + if got := cell(flatRows, rowHeader+i, c+3); got != v { + t.Errorf("单页表第 %d 行第 %d 列 = %q,期望 %q(与分项成绩表逐格一致)", i+1, c+3, got, v) + } + } + } +} + +// A17 的反向钉子:实时结算(默认)下分批发布同样会让「已发布 < 应出成绩」, +// 但那一批当场结算过、名次与积分都在——不能被标成未结算。 +// 同组的犯规行也不许被改写成「未结算」:判罚结果是现场明确给出的,不能弄丢。 +func TestSettledAndPenalisedRowsAreNotMarkedUnsettled(t *testing.T) { + db := openEventDB(t) + seedUnsettledEvent(t, db) + stmts := []string{ + // 已结算:名次与流水都写好了(发布即结算的形态)。 + `UPDATE results SET rank = 1 WHERE entry_id = 1`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`, + // 第二个人改为「已发布 + 犯规」:犯规不占名次,状态列要保住「犯规」。 + `UPDATE results SET state = 'published', status = 'dq' WHERE entry_id = 2`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + + dst := filepath.Join(t.TempDir(), "分项.xlsx") + if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil { + t.Fatalf("导出分项成绩表: %v", err) + } + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + rows, err := f.GetRows("田径·100米跑·男子") + if err != nil { + t.Fatalf("读取分项成绩: %v", err) + } + // 已结算的那条:名次 1、积分 10、状态 有效。 + if got := cell(rows, rowHeader, 0); got != "1" { + t.Errorf("已结算行名次 = %q,期望 1(不该被当成未结算)", got) + } + if got := cell(rows, rowHeader, 5); got != "10" { + t.Errorf("已结算行积分 = %q,期望 10", got) + } + if got := cell(rows, rowHeader, 6); got != "有效" { + t.Errorf("已结算行状态 = %q,期望 有效", got) + } + // 犯规的那条:状态仍是「犯规」,不能被改写成「未结算」(判罚是现场明确给出的结论)。 + dq := findRowByCell(t, rows, 1, "102") + if got := cell(rows, dq, 6); got != "犯规" { + t.Errorf("犯规行状态 = %q,期望 犯规(判罚结果不能被改写成未结算)", got) + } +} + +// findRowByCell 在表里找「第 col 列等于 value」的数据行下标;找不到直接失败。 +func findRowByCell(t *testing.T, rows [][]string, col int, value string) int { + t.Helper() + for r := rowHeader; r < len(rows); r++ { + if cell(rows, r, col) == value { + return r + } + } + t.Fatalf("表里找不到第 %d 列 = %q 的行", col, value) + return -1 +} + // seedTieAndRelay 在 seedEvent 之上补两类现场常见、却最容易把列宽顶爆的数据: // 并列名次(名次列会写成「3(并列)」)与接力队名单(运动员/队 列写成顿号列表)。 func seedTieAndRelay(t *testing.T, db *sql.DB) { @@ -66,7 +213,7 @@ func TestColumnsFitContent(t *testing.T) { }, }, { - name: "团体奖牌明细", + name: "单位奖牌明细", file: "奖牌.xlsx", export: func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) }, sheets: []sheetLayout{ diff --git a/server/internal/export/flat.go b/server/internal/export/flat.go index 898fc35..ff9632d 100644 --- a/server/internal/export/flat.go +++ b/server/internal/export/flat.go @@ -26,6 +26,7 @@ var flatColumns = append([]column{ }, eventColumns...) // ExportFlatXLSX 导出「全部成绩(单页)」到 dst。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportFlatXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadFlatWorkbook(db, opts) if err != nil { diff --git a/server/internal/export/flat_test.go b/server/internal/export/flat_test.go index 1ba39dd..85296f0 100644 --- a/server/internal/export/flat_test.go +++ b/server/internal/export/flat_test.go @@ -114,7 +114,7 @@ func TestFlatMatchesAllXLSX(t *testing.T) { if err != nil { t.Fatalf("读取全部: %v", err) } - // 「全部」前 3 张是总表(总积分榜 / 团体奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。 + // 「全部」前 3 张是总表(总积分榜 / 单位奖牌榜 / 个人积分奖牌榜),其后才是分项 sheet。 sheets := allF.GetSheetList() if len(sheets) != 3+len(wb.Events.Sheets) { t.Fatalf("全部导出 sheet 列表 = %v,与 %d 个分项对不上", sheets, len(wb.Events.Sheets)) diff --git a/server/internal/export/load.go b/server/internal/export/load.go index 9c78b20..fdc0ee3 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -17,6 +17,9 @@ type Meta struct { Subtitle string Organizer string GeneratedAt time.Time + // GeneratedBy 是「导出人 XXX」那一行的取值。当前没有任何生产写入口(所有调用方都只填 EventName 等), + // 所以这一行事实上不会出现在导出件里;空值时 metaSubtitle 直接跳过它(代码卫生项 C28)。 + // 留字段是因为打印件抬头本来就该有「谁导的」——要接线时由 handler 从会话里取用户名填进来即可。 GeneratedBy string } @@ -33,6 +36,11 @@ type Options struct { // IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出; // 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。 // 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。 + // + // 当前没有生产写入口:HTTP 层所有导出 handler 都传 Options{},只有单测(load_test.go 的 + // TestEventRowsIncludeDraftsKeepsDraftValues)设过 true,所以这个分支在生产不可达(代码卫生项 C30)。 + // 保留它是因为「草稿值不外露」的口径必须有对照面才能被测住;真要接线请先想清楚 + // 「谁能看到别人的草稿值」这个权限问题(现在连只读接口都不外露草稿)。 IncludeDrafts bool } @@ -454,19 +462,27 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) { // loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。 // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 // includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished); -// 连成绩行都没有的报名项同样占位(NoResult)。 +// 连成绩行都没有的报名项同样占位(NoResult);成绩已发布但整组还没结算的占位(Unsettled)。 func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) { // 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次, // 口径 = scope=athlete 优先,其次通道 sort_order 小者。 // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // 落不了库)回落到 results.rank,保持名次列不空白。 + // + // gs 是「该 (小项, 组别) 录完了没有」的计数,判据与 httpapi 的 groupStats / scopeSettled 同源: + // 分母只数 status=registered 的报名项(退赛 / 失格本来就不该等它出成绩), + // 分子只数「已发布且有内容」的成绩(判据同 publishableResultCond)。 + // 延迟结算(standings.live_on_publish=false)下两者不等就说明这一组还没结算, + // 行上的名次与积分因此天然是空的——导出必须把它标成「未结算」,见 Unsettled。 query := ` SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''), COALESCE(r.result_text, ''), COALESCE(r.unit, ''), COALESCE((SELECT SUM(l.points) FROM score_ledger l WHERE l.entry_id = en.id AND l.voided = 0), 0), - r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '') + r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, ''), + r.result_value IS NOT NULL, + COALESCE(gs.required, 0), COALESCE(gs.published, 0) FROM entries en JOIN teams t ON t.id = en.team_id LEFT JOIN athletes a ON a.id = en.athlete_id @@ -478,8 +494,19 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([ LIMIT 1) LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id AND cr.category_id = en.category_id + LEFT JOIN ( + SELECT e2.event_id, e2.category_id, COUNT(*) AS required, + SUM(CASE WHEN r2.state = 'published' + AND (r2.result_value IS NOT NULL OR r2.rank IS NOT NULL OR r2.status <> 'ok') + THEN 1 ELSE 0 END) AS published + FROM entries e2 + LEFT JOIN results r2 ON r2.entry_id = e2.id + WHERE e2.event_id = ? AND e2.status = 'registered' + GROUP BY e2.event_id, e2.category_id + ) gs ON gs.event_id = en.event_id AND gs.category_id = en.category_id WHERE en.event_id = ?` - args := []any{eventID} + // 子查询里那个 ? 是同一项(先给 gs 的 event_id,再给外层 WHERE)。 + args := []any{eventID, eventID} if categoryID != 0 { query += ` AND en.category_id = ?` args = append(args, categoryID) @@ -504,10 +531,12 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([ resultStat, resultState sql.NullString entryStat, entryRemark string resultNote string + hasValue int + required, published int ) if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName, &row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat, - &entryRemark, &resultNote); err != nil { + &entryRemark, &resultNote, &hasValue, &required, &published); err != nil { return nil, fmt.Errorf("export: 扫描成绩: %w", err) } row.Participant = participantName(athleteName, memberJSON) @@ -519,6 +548,20 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([ row.NoResult = !resultState.Valid row.Unpublished = !includeDrafts && resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft + // 已发布但整组还没结算:成绩上屏了,名次与积分要等结算(见 Unsettled 的说明)。 + // 这里的条件缺一不可,少任何一个都会误伤—— + // published < required 且都非 0:这一组确实还没结算(与 /api/results/settlement 同判据); + // **state 是 published**:草稿行不算(它没有对外,未发布口径已经把它划走了; + // includeDrafts=true 时草稿行连值一起导出,也必须按草稿呈现,不能被这一分支抢走); + // 有成绩值、状态是 ok:这才是「等名次」的那种行;犯规 / 弃权行本来就不占名次, + // 把它们的「犯规」改写成「未结算」会把现场明确的判罚结果弄丢; + // 名次与积分都空:实时结算(默认)下分批发布也会出现 published < required, + // 但那一批已经结算过、名次与积分都在,不能标成未结算。 + row.Unsettled = published > 0 && published < required && + !row.NoResult && resultState.Valid && + domain.ResultState(resultState.String) == domain.ResultStatePublished && + hasValue != 0 && rank <= 0 && row.Points == 0 && + domain.ResultStatus(resultStat.String) == domain.ResultStatusOK out = append(out, row) } if err := rows.Err(); err != nil { @@ -602,6 +645,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { rows, err := db.Query(` SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''), COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''), + COALESCE(r.unit, ''), l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0) FROM score_ledger l LEFT JOIN events e ON e.id = l.event_id @@ -633,10 +677,14 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { var r TeamDetailRow var source string var athleteID int64 + var resultText, unit string if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, - &r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil { + &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID); err != nil { return nil, fmt.Errorf("export: 扫描积分明细: %w", err) } + // 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩 + // 在一份表里是「11.58 秒」、在另一份里是「11.58」,两张表摆在一起核对时会被当成两个数。 + r.ResultText = displayResult(resultText, unit) r.Source = sourceLabel(source) r.individual = athleteID != 0 out = append(out, r) diff --git a/server/internal/export/medals.go b/server/internal/export/medals.go index 5aa7c01..a34a2d6 100644 --- a/server/internal/export/medals.go +++ b/server/internal/export/medals.go @@ -13,8 +13,8 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) -// medalDetailSheet 是「团体奖牌明细」总表的表名。 -const medalDetailSheet = "团体奖牌明细" +// medalDetailSheet 是「单位奖牌明细」总表的表名。 +const medalDetailSheet = "单位奖牌明细" // 奖牌文案:与名次 1/2/3 一一对应。 const ( @@ -23,7 +23,7 @@ const ( medalBronze = "铜牌" ) -// MedalDetailRow 是团体奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。 +// MedalDetailRow 是单位奖牌明细的一行:某单位的一块奖牌来自哪个小项/组别/名次。 type MedalDetailRow struct { SportName string EventName string @@ -47,7 +47,7 @@ type MedalDetailSheet struct { Rows []MedalDetailRow } -// MedalDetailWorkbook 是「团体奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。 +// MedalDetailWorkbook 是「单位奖牌明细」的内容:一张总表 + 每个有奖牌的单位一张分表。 type MedalDetailWorkbook struct { Meta Meta Sheets []MedalDetailSheet @@ -67,7 +67,8 @@ var medalDetailColumns = []column{ // medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。 var medalTeamColumns = medalDetailColumns[1:] -// ExportMedalDetailXLSX 导出团体奖牌明细,落盘到 dst。 +// ExportMedalDetailXLSX 导出单位奖牌明细,落盘到 dst。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadMedalDetailWorkbook(db, opts) if err != nil { @@ -80,7 +81,7 @@ func ExportMedalDetailXLSX(db *sql.DB, dst string, opts Options) error { return saveWorkbook(f, dst) } -// WriteMedalDetailXLSX 把团体奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。 +// WriteMedalDetailXLSX 把单位奖牌明细写成 xlsx 流,供 HTTP 直接响应下载。 func WriteMedalDetailXLSX(w io.Writer, wb MedalDetailWorkbook) error { f, err := medalDetailFile(wb) if err != nil { @@ -156,7 +157,7 @@ func medalRow(r MedalDetailRow) []any { } // LoadMedalDetailWorkbook 读取全部奖牌来源。 -// 单位顺序沿用「团体奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。 +// 单位顺序沿用「单位奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。 func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, error) { if db == nil { return MedalDetailWorkbook{}, ErrNoDatabase @@ -216,7 +217,7 @@ type medalHit struct { // medalHits 逐「报名项」判定获奖单位。 // // 同一报名项在多条计分通道下会展开成多条名次,只能记一枚奖牌——选通道、联队份额展开、 -// 名次换牌色这些规则都交给 domain 的团体奖牌榜函数(domain.BuildMedalStandings)判定, +// 名次换牌色这些规则都交给 domain 的单位奖牌榜函数(domain.BuildMedalStandings)判定, // 导出侧不复制计奖规则。传入的单位/流水/运动员都为空,返回的行只可能来自该报名项的奖牌。 func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medalHit { out := make(map[int64][]medalHit) diff --git a/server/internal/export/medals_test.go b/server/internal/export/medals_test.go index 8e882b0..e4aaf3f 100644 --- a/server/internal/export/medals_test.go +++ b/server/internal/export/medals_test.go @@ -23,7 +23,7 @@ func addBronzeEntry(t *testing.T, db *sql.DB) { } } -// 团体奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。 +// 单位奖牌明细:总表逐块列出,每个有奖牌的单位一张分表,分表末尾一行小计。 func TestExportMedalDetailXLSX(t *testing.T) { db := openEventDB(t) seedEvent(t, db) // T1 第 1 名、T2 第 2 名 @@ -32,17 +32,17 @@ func TestExportMedalDetailXLSX(t *testing.T) { dst := filepath.Join(t.TempDir(), "奖牌.xlsx") if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil { - t.Fatalf("导出团体奖牌明细: %v", err) + t.Fatalf("导出单位奖牌明细: %v", err) } f := openXLSX(t, dst) defer func() { _ = f.Close() }() - want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"} + want := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } - master, err := f.GetRows("团体奖牌明细") + master, err := f.GetRows("单位奖牌明细") if err != nil { t.Fatalf("读取总表: %v", err) } @@ -105,7 +105,7 @@ func TestExportMedalDetailXLSX(t *testing.T) { } } -// 奖牌明细必须与团体奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。 +// 奖牌明细必须与单位奖牌榜逐单位对得上,包括「个人+团体」双计项目只记一枚奖牌。 func TestMedalDetailMatchesBoard(t *testing.T) { db := openEventDB(t) seedEvent(t, db) @@ -136,7 +136,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) { } board, err := LoadStandingsWorkbook(db, Options{}) if err != nil { - t.Fatalf("读取团体奖牌榜: %v", err) + t.Fatalf("读取单位奖牌榜: %v", err) } // 双计项目只贡献一枚金牌(去重成一条),不是两条。 @@ -176,7 +176,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) { } // 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出 -// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。 +// 大项 / 小项 / 组别,并与单位奖牌榜逐单位对得上(devdocs 17 §11.3)。 func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) { db := openEventDB(t) seedEvent(t, db) @@ -201,7 +201,7 @@ func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) { } board, err := LoadStandingsWorkbook(db, Options{}) if err != nil { - t.Fatalf("读取团体奖牌榜: %v", err) + t.Fatalf("读取单位奖牌榜: %v", err) } for _, row := range board.Medal { gold, silver, bronze := medalCountsOf(wb, row.TeamID) @@ -239,10 +239,10 @@ func TestExportMedalDetailEmptyEvent(t *testing.T) { f := openXLSX(t, dst) defer func() { _ = f.Close() }() - if got := f.GetSheetList(); !equalStrings(got, []string{"团体奖牌明细"}) { + if got := f.GetSheetList(); !equalStrings(got, []string{"单位奖牌明细"}) { t.Fatalf("sheet 列表 = %v,期望只有总表", got) } - rows, err := f.GetRows("团体奖牌明细") + rows, err := f.GetRows("单位奖牌明细") if err != nil { t.Fatalf("读取总表: %v", err) } diff --git a/server/internal/export/standings.go b/server/internal/export/standings.go index 82d02c4..fe64b0f 100644 --- a/server/internal/export/standings.go +++ b/server/internal/export/standings.go @@ -9,7 +9,7 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) -// StandingsWorkbook 是「总表」的内容:总积分榜 + 团体奖牌榜 + 个人积分奖牌榜。 +// StandingsWorkbook 是「总表」的内容:总积分榜 + 单位奖牌榜 + 个人积分奖牌榜。 // 三个榜由 domain 的排名函数算出,本包只负责排版。 type StandingsWorkbook struct { Meta Meta @@ -55,6 +55,7 @@ var ( ) // ExportStandingsXLSX 从赛事库读取并导出总表(三个 sheet),落盘到 dst。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportStandingsXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadStandingsWorkbook(db, opts) if err != nil { @@ -91,7 +92,7 @@ func writeStandingsSheets(f *excelize.File, st *styleSet, wb StandingsWorkbook) rows [][]any }{ {"总积分榜", sheetTitle(meta, "总积分榜"), teamColumns, teamRows(wb.Team)}, - {"团体奖牌榜", sheetTitle(meta, "团体奖牌榜"), medalColumns, medalRows(wb.Medal)}, + {"单位奖牌榜", sheetTitle(meta, "单位奖牌榜"), medalColumns, medalRows(wb.Medal)}, {"个人积分奖牌榜", sheetTitle(meta, "个人积分奖牌榜"), athleteColumns, athleteRows(wb.Athlete)}, } for _, s := range specs { @@ -154,6 +155,7 @@ type AllWorkbook struct { } // ExportAllXLSX 导出总表 + 全部分项成绩到一个多 sheet 工作簿。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportAllXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadAllWorkbook(db, opts) if err != nil { @@ -195,7 +197,7 @@ func allFile(wb AllWorkbook) (*excelize.File, error) { } namer := &sheetNamer{} // 三个榜先登记名称,分项表才不会撞名。 - for _, n := range []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} { + for _, n := range []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"} { namer.add(n) } return writeEventSheets(f, st, wb.Events.Meta, wb.Events.Sheets, namer) diff --git a/server/internal/export/style.go b/server/internal/export/style.go index 2df01d2..9dfc0bf 100644 --- a/server/internal/export/style.go +++ b/server/internal/export/style.go @@ -300,6 +300,12 @@ func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title s } // saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。 +// +// 只被本包那 9 个 Export*XLSX 包装函数调用,而它们**只有测试在调**:生产导出(HTTP 层) +// 一律走 Load* + Write*(w io.Writer, wb),把工作簿直接写进响应体,不落临时盘。 +// 所以整条「写文件」路径在生产不可达(代码卫生项 C28)。作用是让单测真的落盘、再用 +// openXLSX 读回来逐格校验,比只比对内存结构更接近现场拿到的那份文件。 +// 要把它接进生产,先想清楚多一份临时文件与一次磁盘写的代价,别在 handler 里随手用。 func saveWorkbook(f *excelize.File, dst string) error { if strings.TrimSpace(dst) == "" { return errors.New("export: 导出路径为空") diff --git a/server/internal/export/style_test.go b/server/internal/export/style_test.go index 4392834..363a7d0 100644 --- a/server/internal/export/style_test.go +++ b/server/internal/export/style_test.go @@ -1,6 +1,8 @@ package export import ( + "io" + "os" "path/filepath" "strings" "testing" @@ -56,20 +58,47 @@ func TestExportDocPropsAreNeutral(t *testing.T) { } } +// writeWorkbookFile 把「写 xlsx 流」的导出落到磁盘上,供本文件核对文档属性。 +// 落盘是给测试读回用的,生产一律直接写响应体(见 saveWorkbook 的说明)。 +func writeWorkbookFile(dst string, write func(io.Writer) error) error { + f, err := os.Create(dst) + if err != nil { + return err + } + if err := write(f); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + // 所有 xlsx 导出都走 buildWorkbook,文档属性必须一处设好、处处生效。 func TestEveryWorkbookCarriesDocProps(t *testing.T) { db := openEventDB(t) seedEvent(t, db) dir := t.TempDir() + // 表里必须把 9 个落盘导出函数**全部**覆盖到:漏掉哪一个,那份工作簿的文档属性就没人守。 exports := map[string]func(string) error{ "总表.xlsx": func(dst string) error { return ExportStandingsXLSX(db, dst, Options{}) }, + "全部.xlsx": func(dst string) error { return ExportAllXLSX(db, dst, Options{}) }, "分项.xlsx": func(dst string) error { return ExportEventXLSX(db, 1, dst, Options{}) }, "单页.xlsx": func(dst string) error { return ExportFlatXLSX(db, dst, Options{}) }, "奖牌.xlsx": func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) }, "个人明细.xlsx": func(dst string) error { return ExportAthleteDetailXLSX(db, dst, Options{}) }, "单位明细.xlsx": func(dst string) error { return ExportTeamDetailXLSX(db, 1, dst, Options{}) }, "单位总明细.xlsx": func(dst string) error { return ExportAllTeamDetailsXLSX(db, dst, Options{}) }, + "报名名单.xlsx": func(dst string) error { return ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}) }, + // 基础数据包没有落盘版包装函数(生产与这里都走 Load + Write),用流写盘补齐这一项。 + "基础数据包.xlsx": func(dst string) error { + return writeWorkbookFile(dst, func(w io.Writer) error { + wb, err := LoadBaseDataWorkbook(db, Options{}) + if err != nil { + return err + } + return WriteBaseDataXLSX(w, wb) + }) + }, } for name, run := range exports { dst := filepath.Join(dir, name) diff --git a/server/internal/export/team.go b/server/internal/export/team.go index 43d0116..4fa1ca9 100644 --- a/server/internal/export/team.go +++ b/server/internal/export/team.go @@ -48,6 +48,7 @@ var teamDetailColumns = []column{ } // ExportTeamDetailXLSX 导出某单位的积分明细(逐项列出得分来源)。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportTeamDetailXLSX(db *sql.DB, teamID int64, dst string, opts Options) error { wb, err := LoadTeamDetailWorkbook(db, teamID, opts) if err != nil { @@ -108,11 +109,10 @@ func teamDetailDataRows(rows []TeamDetailRow, weight float64) ([][]any, float64) return out, total } -// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时不加合计行。 +// teamDetailRows 把明细行排成表格:数据行后跟一行「合计」;没有流水时也保留合计行(写 0)。 +// 手册(docs/使用手册.md 第 10 节)承诺「末行是合计」,总明细版也恒有合计行; +// 单单位版若在空数据时省掉它,同一份表在两种入口下版式不一致,读表的人会以为少印了一行。 func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any { data, total := teamDetailDataRows(rows, weight) - if len(data) == 0 { - return data - } return append(data, []any{"合计", "", "", "", "", "", "", "", total, ""}) } diff --git a/server/internal/export/team_all.go b/server/internal/export/team_all.go index 1a7b94f..cea8305 100644 --- a/server/internal/export/team_all.go +++ b/server/internal/export/team_all.go @@ -32,6 +32,7 @@ type AllTeamDetailsWorkbook struct { var allTeamDetailColumns = append([]column{{"单位", 22, kindText}}, teamDetailColumns...) // ExportAllTeamDetailsXLSX 导出所有单位的积分明细(总表 + 每单位一张分表),落盘到 dst。 +// 仅测试使用(生产走 Load* + Write* 直接写响应体,不落盘)——见 saveWorkbook 的说明。 func ExportAllTeamDetailsXLSX(db *sql.DB, dst string, opts Options) error { wb, err := LoadAllTeamDetailsWorkbook(db, opts) if err != nil { diff --git a/server/internal/export/xlsx_test.go b/server/internal/export/xlsx_test.go index e6037f3..44254c9 100644 --- a/server/internal/export/xlsx_test.go +++ b/server/internal/export/xlsx_test.go @@ -24,7 +24,7 @@ func TestExportStandingsXLSX(t *testing.T) { } defer func() { _ = f.Close() }() - want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} + want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } @@ -56,12 +56,12 @@ func TestExportStandingsXLSX(t *testing.T) { t.Errorf("第二名单位代码 = %q,期望 T2", got) } - medal, err := f.GetRows("团体奖牌榜") + medal, err := f.GetRows("单位奖牌榜") if err != nil { - t.Fatalf("读取团体奖牌榜: %v", err) + t.Fatalf("读取单位奖牌榜: %v", err) } if len(medal) != 6 { - t.Fatalf("团体奖牌榜行数 = %d,期望 6", len(medal)) + t.Fatalf("单位奖牌榜行数 = %d,期望 6", len(medal)) } if got := cell(medal, 4, 3); got != "1" { // T1 金牌 t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got) @@ -160,7 +160,7 @@ func TestExportAllXLSX(t *testing.T) { } defer func() { _ = f.Close() }() - want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"} + want := []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"} if got := f.GetSheetList(); !equalStrings(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } @@ -207,6 +207,88 @@ func TestExportTeamDetailXLSX(t *testing.T) { if got := cell(rows, 5, 8); got != "10" { t.Errorf("合计积分 = %q,期望 10", got) } + // A18:单位积分明细的成绩列必须带上单位,与分项成绩表 / 个人明细同口径—— + // 同一场比赛的同一个成绩,在一份表里是「11.58 秒」、在另一份里是「11.58」, + // 两张表摆在一起核对时会被当成两个数。 + if got := cell(rows, 4, 6); got != "11.58 秒" { + t.Errorf("明细成绩 = %q,期望 11.58 秒(与分项成绩表同口径)", got) + } +} + +// A18 的对照面:同一个成绩在「单位积分明细」「个人积分总明细」「分项成绩表」里必须逐字一致。 +func TestTeamDetailResultMatchesOtherExports(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dir := t.TempDir() + teamDst := filepath.Join(dir, "单位明细.xlsx") + if err := ExportTeamDetailXLSX(db, 1, teamDst, Options{}); err != nil { + t.Fatalf("导出单位积分明细: %v", err) + } + athDst := filepath.Join(dir, "个人明细.xlsx") + if err := ExportAthleteDetailXLSX(db, athDst, Options{}); err != nil { + t.Fatalf("导出个人积分总明细: %v", err) + } + eventDst := filepath.Join(dir, "分项.xlsx") + if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil { + t.Fatalf("导出分项成绩表: %v", err) + } + + teamRows := readSheet(t, teamDst, "第一分公司积分明细") + athRows := readSheet(t, athDst, "第一分公司个人得分明细") + eventRows := readSheet(t, eventDst, "田径·100米跑·男子") + + // 单位明细第 5 行(0 基下标 4)的张三 100 米;列 6 是「成绩」。 + teamResult := cell(teamRows, 4, 6) + // 个人明细分表数据从同一版式起,列 5 是「成绩」。 + athResult := cell(athRows, 4, 5) + // 分项成绩表列 4 是「成绩」。 + eventResult := cell(eventRows, 4, 4) + want := "11.58 秒" + for name, got := range map[string]string{ + "单位积分明细": teamResult, "个人积分总明细": athResult, "分项成绩表": eventResult, + } { + if got != want { + t.Errorf("%s 里的成绩 = %q,期望 %q", name, got, want) + } + } +} + +// B02:单单位「单位积分明细」在没有任何流水时也要有「合计」行(写 0), +// 与总明细版版式一致,手册承诺的「末行是合计」才成立。 +func TestEmptyTeamDetailKeepsTotalRow(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + // 第三个单位一条流水都没有:它是「新加进来的单位,还没拿分」的现场形态。 + teamID := insertTeam(t, db, "T3", "第三分公司", "三公司", 3, true) + + dst := filepath.Join(t.TempDir(), "单位明细.xlsx") + if err := ExportTeamDetailXLSX(db, teamID, dst, Options{}); err != nil { + t.Fatalf("导出空单位明细: %v", err) + } + rows := readSheet(t, dst, "第三分公司积分明细") + // 4 行版式 + 只有合计行 + if len(rows) != 5 { + t.Fatalf("空单位明细行数 = %d,期望 5(末行合计):%v", len(rows), rows) + } + if got := cell(rows, 4, 0); got != "合计" { + t.Errorf("末行首列 = %q,期望 合计", got) + } + if got := cell(rows, 4, 8); got != "0" { + t.Errorf("空单位合计 = %q,期望 0", got) + } +} + +// readSheet 打开落盘的 xlsx 并读出某张表;导出类用例一律真的写盘再读回,见 saveWorkbook 的说明。 +func readSheet(t *testing.T, path, sheet string) [][]string { + t.Helper() + f := openXLSX(t, path) + defer func() { _ = f.Close() }() + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取 %s!%s: %v", path, sheet, err) + } + return rows } // TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。 diff --git a/server/internal/httpapi/adjustment_enum_test.go b/server/internal/httpapi/adjustment_enum_test.go new file mode 100644 index 0000000..4f935a5 --- /dev/null +++ b/server/internal/httpapi/adjustment_enum_test.go @@ -0,0 +1,55 @@ +package httpapi_test + +import ( + "net/http" + "strings" + "testing" +) + +// C12(后半):CHECK 类非法值必须走 HTTP 就落成 400,而不是一路落到 SQLite 的 +// CHECK 上变成 500 + 原始约束原文。事件枚举、组别性别、通道 scope/basis、纪录 level、 +// 用户 role、成绩 status 都已有用例;人工调整的 category(point_adjustments.category +// 的 CHECK)没有——它的写路由 POST /api/adjustments 到本轮为止一条用例都没有。 +func TestAdjustmentCategoryEnumValidation(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + // 人工调整默认关闭,先打开:否则还没走到 category 就被「未启用」拦下,用例假绿。 + env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`) + + for _, bad := range []string{"bogus", "PENALTY", "罚款", " "} { + status, resp := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{ + "teamId": ids.teamA, "delta": 3, "category": bad, "reason": "测试", + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("category=%q 应 400 VALIDATION_FAILED,得到 %d %+v", bad, status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "category") { + t.Fatalf("错误文案应带字段名 category,得到 %q", resp.Error.Message) + } + // 撞 CHECK 的 500 会带出英文约束原文;这里必须一点都看不到。 + for _, leak := range []string{"CHECK", "constraint", "SQLITE", "sqlite"} { + if strings.Contains(resp.Error.Message, leak) || strings.Contains(string(resp.Error.Detail), leak) { + t.Fatalf("错误里泄露了数据库约束原文 %q:%+v", leak, resp.Error) + } + } + } + // 被拒的请求一条都不许落库。 + if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments`); n != 0 { + t.Fatalf("非法 category 的请求不该落库,得到 %d 行", n) + } + + // 对照:三种合法类别都能落库(CHECK 允许 penalty / bonus / correction)。 + for _, ok := range []string{"penalty", "bonus", "correction"} { + status, after := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{ + "teamId": ids.teamA, "delta": -1, "category": ok, "reason": "测试", + "requestId": "adj-" + ok, + }, true) + if status != http.StatusOK || !after.OK { + t.Fatalf("category=%q 应 200,得到 %d %+v", ok, status, after.Error) + } + } + if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments`); n != 3 { + t.Fatalf("三个合法类别应各落一条,得到 %d 行", n) + } +} diff --git a/server/internal/httpapi/athlete_batch_test.go b/server/internal/httpapi/athlete_batch_test.go index 8f94e2e..6a35eb1 100644 --- a/server/internal/httpapi/athlete_batch_test.go +++ b/server/internal/httpapi/athlete_batch_test.go @@ -1,6 +1,7 @@ package httpapi_test import ( + "fmt" "net/http" "testing" "time" @@ -224,6 +225,98 @@ func TestBatchRejectsBadInput(t *testing.T) { } } +// batchLimit 是服务端单次批量动作的人数上限(`maxAthleteBatch`,与 `entries/batch` 的 +// `maxEntryBatch` 刻意同值 500)。用例在 httpapi 包外,拿不到那个常量,只能写字面量: +// 上限一旦改动,这里与 devdocs/05 会一起变红,正好逼着同步改口径与文档。 +const batchLimit = 500 + +// 超限(上限 + 1):400,响应体与同一个接口上既有的 400 同构,且一个人都不动。 +// +// 用「上限 + 1」构造,不真发几万条:拦的是条数,与 id 具体是什么无关; +// 但条数是真做满的(全是库里存在的人),删掉上限检查后这一批会被真的停用, +// 用例只能靠状态码与文案变红,不会靠「id 不存在」之类的旁证蒙对。 +func TestBatchRejectsOverLimit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + targets := []int64{ids.athleteA, ids.athB} + targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit+1-len(targets))...) + + status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", + map[string]any{"action": "disable", "ids": targets}, true) + if status != http.StatusBadRequest { + t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error) + } + if env2.OK || env2.Error == nil { + t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2) + } + want := fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", batchLimit) + if env2.Error.Message != want { + t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message) + } + + // 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。 + // 对照用「ids 不能为空」(detail 为 nil),与超限同为「只给一句话」的校验。 + refStatus, ref := env.do(client, http.MethodPost, "/api/athletes/batch", + map[string]any{"action": "disable", "ids": []int64{}}, true) + if refStatus != http.StatusBadRequest || ref.Error == nil { + t.Fatalf("对照组(ids 为空)应 400,得到 %d %+v", refStatus, ref) + } + if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code || + (env2.Error.Detail == nil) != (ref.Error.Detail == nil) { + t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error) + } + + // 先校验、后动作:被拒的请求一个人都不能停用。 + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 0 { + t.Fatalf("超限被拒后不该停用任何人,得到 %d 人", n) + } +} + +// 正好等于上限必须放行:边界是「超过」才拒,500 条一次全部停用。 +func TestBatchAllowsExactlyAtLimit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + targets := []int64{ids.athleteA, ids.athB} + targets = append(targets, seedAthleteBatch(t, env, ids.teamA, batchLimit-len(targets))...) + + result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets}) + if result.Requested != batchLimit || result.Succeeded != batchLimit || result.Failed != 0 { + t.Fatalf("正好 %d 条应全部成功,得到 %+v", batchLimit, result) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != batchLimit { + t.Fatalf("应停用 %d 人,得到 %d", batchLimit, n) + } +} + +// seedAthleteBatch 建 count 个运动员并返回其 id,供「正好等于上限」这类要凑够条数的用例使用。 +// 整批放在一个事务里:单连接库上逐条提交会把用例拖成几百次提交。 +func seedAthleteBatch(t *testing.T, env *testEnv, teamID int64, count int) []int64 { + t.Helper() + tx, err := env.ev.Begin() + if err != nil { + t.Fatalf("开事务: %v", err) + } + defer func() { _ = tx.Rollback() }() + + out := make([]int64, 0, count) + for i := 0; i < count; i++ { + res, err := tx.Exec(`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, + fmt.Sprintf("批量%03d", i), teamID) + if err != nil { + t.Fatalf("建运动员: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("取运动员 id: %v", err) + } + out = append(out, id) + } + if err := tx.Commit(); err != nil { + t.Fatalf("提交: %v", err) + } + return out +} + // 未登录的写操作一律 401,且不改动任何数据。 func TestBatchRequiresLogin(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/httpapi/backup_restore_safety_test.go b/server/internal/httpapi/backup_restore_safety_test.go index c8e144e..08a6b1e 100644 --- a/server/internal/httpapi/backup_restore_safety_test.go +++ b/server/internal/httpapi/backup_restore_safety_test.go @@ -81,6 +81,11 @@ func TestRestorePrunesOldSafetyCopies(t *testing.T) { if !strings.Contains(result.Message, "已清理 2 份") { t.Errorf("回执应说明清理了几份:%q", result.Message) } + // A19:保留份数这句话必须固定出现(用户文档只承诺「不参与 20 份滚动清理」), + // 否则「已清理 2 份」读起来像一次性事件,看不出这是个每类库只留 5 份的常态策略。 + if want := fmt.Sprintf("每类库保留最近 %d 份", export.DefaultSafetyCopyKeep); !strings.Contains(result.Message, want) { + t.Errorf("回执应固定说明安全副本保留 %d 份,得到 %q", export.DefaultSafetyCopyKeep, result.Message) + } left := safetyCopiesIn(t, backupsDir, "pre-restore-event-") if len(left) != 5 { diff --git a/server/internal/httpapi/backup_restore_test.go b/server/internal/httpapi/backup_restore_test.go index b9133d3..b937e1a 100644 --- a/server/internal/httpapi/backup_restore_test.go +++ b/server/internal/httpapi/backup_restore_test.go @@ -1,13 +1,21 @@ package httpapi_test import ( + "bytes" + "context" "database/sql" + "encoding/json" + "errors" + "fmt" + "io" "net/http" "os" "path/filepath" "strings" "testing" + "time" + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) @@ -241,6 +249,15 @@ func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) { if result.Message == "" || !strings.Contains(result.Message, result.SafetyCopy) { t.Errorf("回执提示应带上安全副本名:%q", result.Message) } + // A19:安全副本只留最近 N 份这件事必须固定说在回执里。用户文档只承诺「不参与 20 份滚动清理」, + // 现场很容易理解成「它会一直在」,想退回三周前那个时间点才发现早被后几次恢复的清理删了。 + // 本次没有清理任何副本(pruned=0)也要说这句;没删就别说「已清理」。 + if want := fmt.Sprintf("每类库保留最近 %d 份", export.DefaultSafetyCopyKeep); !strings.Contains(result.Message, want) { + t.Errorf("回执应固定说明安全副本保留 %d 份,得到 %q", export.DefaultSafetyCopyKeep, result.Message) + } + if strings.Contains(result.Message, "已清理") { + t.Errorf("本次没清理任何副本,回执不该出现「已清理」:%q", result.Message) + } // ① 当前库回到备份时刻:备份之后加的单位没了,原有两个还在。 if got := countRowsAt(t, eventPath, "teams"); got != 2 { @@ -276,6 +293,169 @@ func TestRestoreBackupRevertsEventDBAndKeepsSafetyCopy(t *testing.T) { } } +// probeRestore 发一个**带自身超时**的恢复请求,返回状态码与信封;超时(服务端把这个请求 +// 挂住不放)时返回 error。 +// +// 为什么不用 env.do:这条用例的探测请求探的正是「第二个恢复请求会不会被挂住排队」。 +// 若用不带超时的请求,被测实现一旦退化成排队,探测本身就会永远等不到响应,把整个用例 +// 堵死在 client.Do 里——defers 与 t.Cleanup 全都轮不到执行,于是整个包挂到 go test +// 超时(现场表现是 600s 死掉 + panic dump 指着这条用例),而不是干脆报红。 +func probeRestore(client *http.Client, url, csrf string, body any, timeout time.Duration) (int, envelope, error) { + raw, err := json.Marshal(body) + if err != nil { + return 0, envelope{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url+"/api/backup/restore", bytes.NewReader(raw)) + if err != nil { + return 0, envelope{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + resp, err := client.Do(req) + if err != nil { + return 0, envelope{}, err + } + defer func() { _ = resp.Body.Close() }() + out, _ := io.ReadAll(resp.Body) + var env envelope + if len(bytes.TrimSpace(out)) > 0 { + if err := json.Unmarshal(out, &env); err != nil { + return resp.StatusCode, env, fmt.Errorf("解析响应: %w (%s)", err, out) + } + } + return resp.StatusCode, env, nil +} + +// A21:恢复必须互斥。两个管理员同时点「一键恢复」时,第二个请求要**当场**拿到 +// 409 +「正在恢复,请稍后」,而不是排队等着一起动库文件(两次替换交错的后果是库被换成 +// 哪一份都说不清,两份回执还各说各话)。 +// +// 构造:第一个请求故意把 JSON body 分两段发——它拿到互斥锁后会阻塞在解码上, +// 这正是生产里「一次恢复正在进行」的形态;此时第二个请求进来必须被挡回。 +// +// 这条用例的要求不止「能抓到回归」,还有「抓到时必须干脆地失败」:它天然要留一个卡在 +// 服务端的半截请求,任何一步无上限地等待,回归发生时就会变成整包挂到 go test 超时。 +// 所以三处都封了上限——探测请求各自带 1s 超时(永远不会把测试主 goroutine 堵住)、 +// 收尾用 t.Cleanup 主动取消第一个在飞请求并让假 body 立刻报错(服务端 handler 随即 +// 返回,httptest.Server.Close() 不会等它)、最后一等也带 20s 上限。 +func TestRestoreRejectsSecondRequestWhileBusy(t *testing.T) { + env, _, _ := backupRestoreEnv(t) + env.seed(t) + client := env.loginAdmin(t) + csrf := env.csrfToken(client) + + 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-") + + // 半截 body 的恢复请求:只发一半 JSON,让它在 handler 里卡住。 + pr, pw := io.Pipe() + // 这个请求要一直挂着,所以它的取消必须挂在清理路径上(断言失败时也执行): + // cancel 让客户端侧的在飞请求当场结束(连接随之断开,服务端读 body 立刻报错), + // 关掉写端再补一层——两条路任意一条走通,handler 都会立刻返回并释放互斥锁, + // 不会把 httptest.Server.Close() 卡在那里等。 + ctx, cancelFirst := context.WithCancel(context.Background()) + // t.Cleanup 按后进先出执行,本函数注册的比环境里的 srv.Close 晚,所以先于它跑。 + t.Cleanup(func() { + cancelFirst() + _ = pw.CloseWithError(io.ErrClosedPipe) + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, env.server.URL+"/api/backup/restore", pr) + if err != nil { + t.Fatalf("构造请求: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + first := make(chan struct { + status int + env envelope + }, 1) + go func() { + resp, err := client.Do(req) + if err != nil { + first <- struct { + status int + env envelope + }{status: 0} + return + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + var out envelope + _ = json.Unmarshal(raw, &out) + first <- struct { + status int + env envelope + }{status: resp.StatusCode, env: out} + }() + if _, err := io.WriteString(pw, `{"kind":"event","file":"`+eventBackup.Name+`"`); err != nil { + t.Fatalf("写半截 body: %v", err) + } + + // 第二个请求:用一份**故意非法**的载荷反复试——它一旦被放行也只会拿 400(校验在任何 + // 文件操作之前),不会把库改掉;拿不到 409 说明第一个请求还没进 handler,再试一次即可。 + // 这样就不必靠 sleep 去赌时序。 + const probeTimeout = time.Second + const deadlineWindow = 5 * time.Second + deadline := time.Now().Add(deadlineWindow) + var ( + lastStatus int + lastError *apiErrorBody + queued bool + ) + for { + status, resp, err := probeRestore(client, env.server.URL, csrf, + map[string]any{"file": "system-not-a-backup.txt", "kind": "system"}, probeTimeout) + if err == nil && status == http.StatusConflict { + if resp.Error == nil || !strings.Contains(resp.Error.Message, "正在恢复,请稍后") { + t.Fatalf("并发恢复的提示 = %+v,期望「正在恢复,请稍后」", resp.Error) + } + break + } + switch { + case err == nil: + // 只记第一次拿到的回应:后面反复试可能自己撞上限流(429),那反而不是要报的事实。 + if lastStatus == 0 { + lastStatus, lastError = status, resp.Error + } + case errors.Is(err, context.DeadlineExceeded): + // 探测请求被服务端挂住了:正是「排队」回归的形态,记下来,等总时限到了好报清楚。 + queued = true + default: + t.Fatalf("并发恢复探测请求失败: %v", err) + } + if time.Now().After(deadline) { + if queued { + t.Fatalf("第一个恢复请求占着互斥锁时,第二个恢复请求被挂住排队(%s 内没有回话);"+ + "应当当场回 409「正在恢复,请稍后」,而不是排队等它做完", probeTimeout) + } + t.Fatalf("第二个恢复请求在 %s 内没拿到 409「正在恢复,请稍后」(第一次拿到的回应 status=%d %+v);"+ + "要么互斥没让它撞上「正在恢复」,要么它根本没能进到互斥判断", deadlineWindow, lastStatus, lastError) + } + time.Sleep(10 * time.Millisecond) + } + + // 收尾:把 body 补完,第一个请求照常做完这次恢复(回执正常,库确实被换回备份时刻)。 + if _, err := io.WriteString(pw, `}`); err != nil { + t.Fatalf("补完 body: %v", err) + } + _ = pw.Close() + // 带超时地等:这一步要是无限等,被测实现的其它退化(例如恢复本身挂住)同样会把包拖到 + // 测试超时,报出来的错还不如这里一句「第一个恢复请求没在 20s 内回话」。 + select { + case got := <-first: + if got.status != http.StatusOK || !got.env.OK { + t.Fatalf("第一个恢复请求应正常完成,得到 status=%d %+v", got.status, got.env.Error) + } + case <-time.After(20 * time.Second): + t.Fatalf("第一个恢复请求在 20s 内没有回话(body 已补完,它本该照常做完这次恢复)") + } +} + // 恢复系统库:只换 system.db,赛事库一行不动;审计照旧写进赛事库。 func TestRestoreSystemBackupLeavesEventDBAlone(t *testing.T) { env, eventPath, dataDir := backupRestoreEnv(t) diff --git a/server/internal/httpapi/display_command_test.go b/server/internal/httpapi/display_command_test.go index 4b02f9e..96ba85e 100644 --- a/server/internal/httpapi/display_command_test.go +++ b/server/internal/httpapi/display_command_test.go @@ -217,3 +217,37 @@ func TestDisplayCommandRejectsUnknownAction(t *testing.T) { t.Fatalf("未知 action 不该下发,得到 %v", sender.sent) } } + +// C27:announce_clear 与其它指令同路(realtime.CmdAnnounceClear 已在协议清单里), +// 不再需要「转发前先问实时层认不认」的特判。这条用例把两件事一起钉住: +// 指令要下发(否则大屏挂着旧公告),持久值也要清空(否则大屏重连又把它显示出来)。 +func TestDisplayCommandAnnounceClearForwardsAndClears(t *testing.T) { + sender := &strictDisplaySender{} + env, client := newDisplayEnv(t, sender) + + if status, e := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ + "action": "announce", + "payload": map[string]any{"text": "决赛推迟到 15:00", "seconds": 30}, + }, true); status != http.StatusOK { + t.Fatalf("先发一条公告应 200,得到 %d %+v", status, e.Error) + } + if got := settingValue(t, env, "display.announce_text"); got != "决赛推迟到 15:00" { + t.Fatalf("公告应已落库,得到 %q", got) + } + + sender.sent = nil + status, env2 := env.do(client, http.MethodPost, "/api/display/command", + map[string]any{"action": "announce_clear"}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("清空公告应 200,得到 %d %+v", status, env2.Error) + } + if len(sender.sent) != 1 || sender.sent[0] != "announce_clear" { + t.Fatalf("清空公告必须下发给实时层,得到 %+v", sender.sent) + } + if got := settingValue(t, env, "display.announce_text"); got != "" { + t.Fatalf("清空公告应把持久值写成空串,得到 %q", got) + } + if after := waitDisplayAudit(t, env); !strings.Contains(after, `"action":"announce_clear"`) { + t.Fatalf("清空公告也要留 display.command 审计,得到 %s", after) + } +} diff --git a/server/internal/httpapi/entry_heat_test.go b/server/internal/httpapi/entry_heat_test.go index a1e0e6d..0b21abf 100644 --- a/server/internal/httpapi/entry_heat_test.go +++ b/server/internal/httpapi/entry_heat_test.go @@ -134,6 +134,66 @@ func TestSetEntriesHeatValidatesInput(t *testing.T) { assertNoHeat(t, env, ids.entryA) } +// entryHeatBatchLimit 与 POST /api/entries/batch 的 `maxEntryBatch` 刻意同值 500: +// 两个接口处理的都是「一批报名项」,上限与文案就该是同一句(服务端直接复用同一个常量)。 +// 用例在 httpapi 包外拿不到常量,写字面量;上限改动时这里会红。 +const entryHeatBatchLimit = 500 + +// 超限(上限 + 1):400,响应体与既有 400 同构,且一条报名项的批次都没改。 +// +// 501 条全是同一个小项里的**真实**报名项(复用 partial_publish_test.go 的 seedBatch: +// 同样是在 seed 基础上补齐到 N 条):删掉上限检查后这一批会被真的写进库, +// 用例只能靠状态码与文案变红,不会靠「部分报名项不存在」之类的旁证蒙对。 +func TestSetEntriesHeatRejectsOverLimit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + targets := seedBatch(t, env, ids, entryHeatBatchLimit+1) + + status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", + map[string]any{"entryIds": targets, "heat": 1}, true) + if status != http.StatusBadRequest { + t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error) + } + if env2.OK || env2.Error == nil { + t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2) + } + want := fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", entryHeatBatchLimit) + if env2.Error.Message != want { + t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message) + } + + // 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。 + refStatus, ref := env.do(client, http.MethodPut, "/api/entries/heat", + map[string]any{"entryIds": []int64{}, "heat": 1}, true) + if refStatus != http.StatusBadRequest || ref.Error == nil { + t.Fatalf("对照组(entryIds 为空)应 400,得到 %d %+v", refStatus, ref) + } + if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code || + (env2.Error.Detail == nil) != (ref.Error.Detail == nil) { + t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error) + } + + // 先校验、后写库:被拒的请求一条批次都不能落。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE heat IS NOT NULL`); n != 0 { + t.Fatalf("超限被拒后不该写任何批次,得到 %d 条", n) + } +} + +// 正好等于上限必须放行:边界是「超过」才拒,500 条同小项的报名项一次写成同一批次。 +func TestSetEntriesHeatAllowsExactlyAtLimit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + targets := seedBatch(t, env, ids, entryHeatBatchLimit) + + status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", + map[string]any{"entryIds": targets, "heat": 2}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("正好 %d 条应放行,得到 %d %+v", entryHeatBatchLimit, status, env2.Error) + } + if result := decodeData[map[string]any](t, env2); result["updated"] != float64(entryHeatBatchLimit) { + t.Fatalf("应更新 %d 条,得到 %+v", entryHeatBatchLimit, result) + } + assertCount(t, env, `SELECT COUNT(*) FROM entries WHERE heat = 2`, entryHeatBatchLimit) +} + // 权限:guest 未登录 401;recorder 放行;批次写操作进审计。 func TestSetEntriesHeatPermissionAndAudit(t *testing.T) { env, ids, _ := recorderWithSeed(t) diff --git a/server/internal/httpapi/enums.go b/server/internal/httpapi/enums.go index a29b749..ec8376d 100644 --- a/server/internal/httpapi/enums.go +++ b/server/internal/httpapi/enums.go @@ -2,7 +2,9 @@ package httpapi import "strings" -// 枚举型入参的取值校验集中在本文件。 +// 枚举型入参的取值校验集中在本文件(**但不全**:`entries.status` 的归一只有导入那条路走 +// entry_status.go 的 normalizeImportStatus;`scope` / `basis` / `records.level` / `/api/users` +// 的 `role`,以及性别、`avgMode` 等仍在各自原处)。 // // 这些列在迁移里都写了 CHECK (col IN (...)),但 CHECK 是最后一道闸:非法值一路落到 // SQLite 才被拒,报出来的是原始约束错误,被统一错误处理当成服务端异常,前端看到 @@ -10,8 +12,10 @@ import "strings" // // 每个函数都直接调用 badRequest:拦截点靠它捕获调用栈,中间再包一层错误构造 helper // 会让拦截点退化成 response.go 里的构造函数(有测试钉着,见 intercept_log_test.go)。 -// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;返回值做 TrimSpace, -// 避免「前后带空格」这种近失值又落回 CHECK。 +// 空值一律给建表默认值,与原先 orDefault(...) 的默认值保持一致;本文件里的函数返回值都做 +// TrimSpace,避免「前后带空格」这种近失值又落回 CHECK。 +// ⚠️ 例外:`normalizeAvgMode` 还在 repo_events.go(本文件之外),且**不做 TrimSpace**—— +// 前后带空格的 `valid` / `all` 会被直接拒成 400,不会被裁掉(P2-C24b)。 // normalizeEventType 校验 events.event_type。 // 取值来自 0004 的 CHECK,0016 用 writable_schema 追加了 individual_team。 diff --git a/server/internal/httpapi/enums_test.go b/server/internal/httpapi/enums_test.go index a50950f..338a49b 100644 --- a/server/internal/httpapi/enums_test.go +++ b/server/internal/httpapi/enums_test.go @@ -90,7 +90,10 @@ func TestUpdateEventEnumValidation(t *testing.T) { client := recorderClient(t, env) // 非法值在事务前就被拦下,不该碰库。 + // 请求体带上必填字段:更新路径已与新建同口径(缺 sportId / code / name 先报「必填」), + // 这条用例钉的是枚举的拦截点,别让它被更早的必填校验挡在前面。 status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{ + "sportId": ids.sport, "code": "M100", "name": "100米跑", "eventType": "bogus", }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { diff --git a/server/internal/httpapi/export_basedata_test.go b/server/internal/httpapi/export_basedata_test.go index d5833db..8afe210 100644 --- a/server/internal/httpapi/export_basedata_test.go +++ b/server/internal/httpapi/export_basedata_test.go @@ -4,6 +4,7 @@ import ( "bytes" "database/sql" "encoding/json" + "fmt" "io" "mime/multipart" "net/http" @@ -306,6 +307,72 @@ func TestBaseDataImportNeverDeletes(t *testing.T) { } } +// A20:基础数据包里带了「已彻底删除」的单位时,导入必须说清「这行被跳过了、因为单位已删除」。 +// 旧行为是照常计入「无变化 / 更新」并写库:现场点预览看到「更新 1 条」,确认写入后界面上仍然 +// 没有这个单位(它还是隐藏状态),看不出任何解释,像是导入没生效。 +func TestBaseDataImportSkipsPurgedTeamAndSaysWhy(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + // 彻底删除 B 单位:假删除——deleted_at 非空、active=0,业务数据一行不动。 + status, resp := env.do(client, http.MethodDelete, + fmt.Sprintf("/api/teams/%d?purge=1", ids.teamB), nil, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, resp.Error) + } + + pack := buildTestXLSX(t, []testSheet{{ + name: "单位", + rows: [][]string{ + {"单位编码", "单位名称"}, + {"B", "第二分公司(导入改名)"}, + }, + }}) + + // 预览:该行必须单列一条说明,且不计进「新增 / 更新 / 无变化」。 + res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"}) + if res.Added != 0 || res.Updated != 0 || res.Unchanged != 0 { + t.Errorf("命中已删除单位的那行不该计入变更:新增 %d / 更新 %d / 无变化 %d", + res.Added, res.Updated, res.Unchanged) + } + if res.ErrorCount != 1 || len(res.Errors) != 1 { + t.Fatalf("应单列 1 条说明,得到 错误 %d %v", res.ErrorCount, res.Errors) + } + // 文案里只给界面路径:这条会直接显示给现场操作员,不该出现内部接口路径。 + for _, want := range []string{"第 2 行", "B", "已被彻底删除", "跳过", "基础数据 → 参赛单位", "恢复"} { + if !strings.Contains(res.Errors[0], want) { + t.Errorf("说明 %q 应包含 %q", res.Errors[0], want) + } + } + if strings.Contains(res.Errors[0], "/api/") { + t.Errorf("给现场看的说明不该带内部接口路径:%q", res.Errors[0]) + } + + // 落地:同样跳过,不改它的字段(改了名它照样是隐藏的,现场只会更困惑)。 + res = importPackage(t, env, client, pack, nil) + if res.ErrorCount != 1 || res.Updated != 0 { + t.Fatalf("落地也应跳过该行,得到 错误 %d / 更新 %d %v", res.ErrorCount, res.Updated, res.Errors) + } + var ( + name string + deleted sql.NullString + activeIn int + ) + if err := env.ev.QueryRow(`SELECT name, deleted_at, active FROM teams WHERE id = ?`, ids.teamB). + Scan(&name, &deleted, &activeIn); err != nil { + t.Fatalf("读单位: %v", err) + } + if name != "第二分公司" { + t.Errorf("单位名 = %q,期望仍是「第二分公司」(隐藏单位的字段一行都不该被改)", name) + } + if !deleted.Valid || activeIn != 0 { + t.Errorf("隐藏状态被改动了:deleted_at=%v active=%d", deleted, activeIn) + } +} + // 新建的单位 / 大项不能被建成停用:缺省 = 启用。 func TestBaseDataImportCreatesEnabledRows(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/httpapi/export_basedata_tristate_test.go b/server/internal/httpapi/export_basedata_tristate_test.go new file mode 100644 index 0000000..4beede6 --- /dev/null +++ b/server/internal/httpapi/export_basedata_tristate_test.go @@ -0,0 +1,252 @@ +package httpapi_test + +import ( + "strconv" + "testing" +) + +// C34:「列缺席 / 格子空 / 显式值」三态口径按铁律每一列都要有对照用例。 +// +// 本轮之前只有排序与布尔列有这三组对照;权重 / 年龄 / 文本列没有—— +// 而这三类各自的口径并不一样(都来自文档写死的行为): +// +// 列缺席 = 不动该字段(四张表、所有列一律如此) +// 格子空(权重) = 取默认值 1 +// 格子空(年龄) = 「不限」(清成 NULL) +// 格子空(文本) = 清空 +// 显式值 = 照写 +// +// 三态本来是分两轮踩出来的坑(「缺列把全部停用改回启用」「留空被重置成默认」), +// 每一列都必须自己钉住,否则改一列的口径不会有人发现。 +type baseDataTriStateColumn struct { + sheet string + header string + seed string // 库里先改成这个非默认值 + blank string // 格子空之后的期望库值 + explicit string // 表格里显式填的合法值 + want string // 显式填值之后的期望库值(数值列会归一化) + rows int // 这张表里会被整列改写影响到的数据行数(单位表有两行,其余各一行) + apply func(t *testing.T, env *testEnv, ids seedIDs) + read func(t *testing.T, env *testEnv, ids seedIDs) string +} + +// affectedRows 是整列改写会改动到的行数:改一列就是改这张表每一行的这一列。 +func (c baseDataTriStateColumn) affectedRows() int { + if c.rows <= 0 { + return 1 + } + return c.rows +} + +// textCol 读某表某列的文本现值(NULL 按空串)。 +func textCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string { + return func(t *testing.T, env *testEnv, ids seedIDs) string { + t.Helper() + var v string + if err := env.ev.QueryRow( + `SELECT COALESCE(`+col+`, '') FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil { + t.Fatalf("读 %s.%s: %v", table, col, err) + } + return v + } +} + +// floatCol 读某表某列的数值现值;用与差异报告同一个格式化口径(formatFloat 的 %g), +// 这样「2.5 与 2.50 是同一个值」不会让断言假红。 +func floatCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string { + return func(t *testing.T, env *testEnv, ids seedIDs) string { + t.Helper() + var v float64 + if err := env.ev.QueryRow(`SELECT `+col+` FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil { + t.Fatalf("读 %s.%s: %v", table, col, err) + } + return strconv.FormatFloat(v, 'g', -1, 64) + } +} + +// intCol 读某表某列的可空整数现值(NULL 按空串 = 不限)。 +func intCol(table, col string) func(t *testing.T, env *testEnv, ids seedIDs) string { + return func(t *testing.T, env *testEnv, ids seedIDs) string { + t.Helper() + var v *int + if err := env.ev.QueryRow(`SELECT `+col+` FROM `+table+` WHERE id = ?`, idsFor(table, ids)).Scan(&v); err != nil { + t.Fatalf("读 %s.%s: %v", table, col, err) + } + if v == nil { + return "" + } + return strconv.Itoa(*v) + } +} + +// idsFor 把「表 → 种子行的 id」这件事收在一处:三张表在种子里各只有一行。 +func idsFor(table string, ids seedIDs) int64 { + switch table { + case "teams": + return ids.teamA + case "events": + return ids.event + default: + return ids.category + } +} + +// triStateColumns 是本条要覆盖的三态列:权重(项目与组别)、年龄上下限、场地 / 备注 / 颜色三列文本。 +func triStateColumns() []baseDataTriStateColumn { + return []baseDataTriStateColumn{ + { + sheet: "小项", header: "权重", rows: 1, + seed: "2.5", blank: "1", explicit: "3.5", want: "3.5", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE events SET multiplier = 2.5 WHERE id = ?`, ids.event) + }, + read: floatCol("events", "multiplier"), + }, + { + sheet: "组别", header: "权重", rows: 1, + seed: "4", blank: "1", explicit: "0.5", want: "0.5", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE event_categories SET multiplier = 4 WHERE id = ?`, ids.category) + }, + read: floatCol("event_categories", "multiplier"), + }, + { + sheet: "组别", header: "年龄下限", rows: 1, + seed: "30", blank: "", explicit: "12", want: "12", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE event_categories SET age_min = 30, age_max = 40 WHERE id = ?`, ids.category) + }, + read: intCol("event_categories", "age_min"), + }, + { + sheet: "组别", header: "年龄上限", rows: 1, + seed: "40", blank: "", explicit: "45", want: "45", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE event_categories SET age_min = 30, age_max = 40 WHERE id = ?`, ids.category) + }, + read: intCol("event_categories", "age_max"), + }, + { + sheet: "小项", header: "场地", rows: 1, + seed: "田径场A", blank: "", explicit: "副场", want: "副场", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE events SET venue = '田径场A' WHERE id = ?`, ids.event) + }, + read: textCol("events", "venue"), + }, + { + sheet: "小项", header: "备注", rows: 1, + seed: "雨后备注", blank: "", explicit: "改过的备注", want: "改过的备注", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE events SET remark = '雨后备注' WHERE id = ?`, ids.event) + }, + read: textCol("events", "remark"), + }, + { + // 单位表有两行数据:整列改写会同时动到两个单位,期望条数跟着行数走。 + sheet: "单位", header: "颜色", rows: 2, + seed: "#FF0000", blank: "", explicit: "#00FF00", want: "#00FF00", + apply: func(t *testing.T, env *testEnv, ids seedIDs) { + env.exec(t, `UPDATE teams SET color = '#FF0000' WHERE id = ?`, ids.teamA) + env.exec(t, `UPDATE teams SET color = '#0000FF' WHERE id = ?`, ids.teamB) + }, + read: textCol("teams", "color"), + }, + } +} + +func TestBaseDataImportTriStateColumns(t *testing.T) { + for _, col := range triStateColumns() { + t.Run(col.sheet+"/"+col.header, func(t *testing.T) { + // 三个期望值必须两两不同,否则「不做区分」的实现也能让用例全绿。 + if col.seed == col.blank || col.seed == col.want || col.blank == col.want { + t.Fatalf("三态期望值必须两两不同:seed=%q blank=%q explicit=%q", col.seed, col.blank, col.want) + } + + t.Run("列缺席=不动", func(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + col.apply(t, env, ids) + client := baseDataAdminClient(t, env) + + f := exportBaseDataWorkbook(t, env, client) + dropColumn(t, f, col.sheet, col.header) + 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) + } + assertNoFieldDiff(t, res, col.header) + + res = importPackage(t, env, client, pack, nil) + if res.Added != 0 || res.Updated != 0 { + t.Fatalf("落地后也不该有改动:新增 %d / 更新 %d", res.Added, res.Updated) + } + if got := col.read(t, env, ids); got != col.seed { + t.Fatalf("「%s」缺列后库里应保持原值 %q,得到 %q", col.header, col.seed, got) + } + }) + + t.Run("格子空=按本列口径", func(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + col.apply(t, env, ids) + client := baseDataAdminClient(t, env) + + f := exportBaseDataWorkbook(t, env, client) + rewriteColumn(t, f, col.sheet, col.header, "") + pack := writeWorkbook(t, f) + + res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"}) + if res.ErrorCount != 0 || res.Added != 0 { + t.Fatalf("格子空不该报错、也不该新建:新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors) + } + // 预览必须先报出这一列的差异再说:预览说「无变化」而落地改了,是最坏的一种不一致。 + // 三态期望值两两不同(上面断言过),所以格子空必然产生差异。 + if res.Updated != col.affectedRows() { + t.Fatalf("格子空应报 %d 条更新,得到 %d %+v", col.affectedRows(), res.Updated, res.Changes) + } + assertNoFieldDiff(t, res, "单位名称", "大项名称", "小项名称", "组别名") + + res = importPackage(t, env, client, pack, nil) + if res.ErrorCount != 0 { + t.Fatalf("落地不该报错,得到 %v", res.Errors) + } + if got := col.read(t, env, ids); got != col.blank { + t.Fatalf("「%s」格子空后库里应为 %q(本列口径),得到 %q", col.header, col.blank, got) + } + }) + + t.Run("显式值=照写", func(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + col.apply(t, env, ids) + client := baseDataAdminClient(t, env) + + f := exportBaseDataWorkbook(t, env, client) + rewriteColumn(t, f, col.sheet, col.header, col.explicit) + pack := writeWorkbook(t, f) + + res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"}) + if res.ErrorCount != 0 || res.Added != 0 || res.Updated != col.affectedRows() { + t.Fatalf("显式填 %q 应报 %d 条更新、无错误,得到 新增 %d / 更新 %d / 错误 %d %v", + col.explicit, col.affectedRows(), res.Added, res.Updated, res.ErrorCount, res.Errors) + } + res = importPackage(t, env, client, pack, nil) + if res.Updated != col.affectedRows() { + t.Fatalf("落地应更新 %d 条,得到 %d", col.affectedRows(), res.Updated) + } + if got := col.read(t, env, ids); got != col.want { + t.Fatalf("「%s」显式填 %q 后库里应为 %q,得到 %q", col.header, col.explicit, col.want, got) + } + // 再导入一次同样的包:已经等于目标值,必须回到「无变化」。 + res = importPackage(t, env, client, pack, nil) + if res.Updated != 0 { + t.Fatalf("同一份包再导一次应无变化,得到 %d %+v", res.Updated, res.Changes) + } + }) + }) + } +} diff --git a/server/internal/httpapi/export_content_disposition_test.go b/server/internal/httpapi/export_content_disposition_test.go index 9da4d25..05cd0b3 100644 --- a/server/internal/httpapi/export_content_disposition_test.go +++ b/server/internal/httpapi/export_content_disposition_test.go @@ -52,10 +52,12 @@ func TestExportContentDispositionHasASCIIFallback(t *testing.T) { {"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", ".xlsx"}, {"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", ".xlsx"}, {"/api/export/all-flat.xlsx", "all-flat.xlsx", "赛事成绩总表(单页).xlsx", ".xlsx"}, - {"/api/export/medals.xlsx", "medals.xlsx", "团体奖牌明细.xlsx", ".xlsx"}, + {"/api/export/medals.xlsx", "medals.xlsx", "单位奖牌明细.xlsx", ".xlsx"}, {"/api/export/athletes.xlsx", "athletes.xlsx", "个人积分总明细.xlsx", ".xlsx"}, {"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", ".xlsx"}, {"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", ".xlsx"}, + {"/api/export/entries.xlsx", "entries.xlsx", "报名名单.xlsx", ".xlsx"}, + {"/api/export/basedata.xlsx", "basedata.xlsx", "基础数据包.xlsx", ".xlsx"}, {"/api/export/project.zip", "project.zip", "赛事项目包.zip", ".zip"}, } for _, tc := range cases { diff --git a/server/internal/httpapi/export_flat_test.go b/server/internal/httpapi/export_flat_test.go index b1023ea..560a97c 100644 --- a/server/internal/httpapi/export_flat_test.go +++ b/server/internal/httpapi/export_flat_test.go @@ -95,8 +95,8 @@ func TestExistingExportsUnchanged(t *testing.T) { file string sheets []string }{ - {"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}}, - {"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}}, + {"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜"}}, + {"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "单位奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}}, {"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}}, {"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}}, {"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", []string{"第一分公司积分明细"}}, diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index e8fefa3..bac0547 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" @@ -24,8 +25,27 @@ import ( const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" -// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。 -const maxProjectImportBytes = 512 << 20 +// maxProjectImportBytes 是赛事包上传的硬上限。 +// +// **故意与另外两个批量导入(名单 CSV 32MB、基础数据包 32MB)不同**:赛事包是唯一 +// 带二进制资源的包——库里导出 + 赛事会标 / 大屏背景图 / 单位 logo,以及**字体** +// (字体上传的单文件上限本身就是 20MB,见 fonts.go 的 maxFontBytes,且允许传多个)。 +// 按 32MB 卡会让「两个字体 + 一张背景图」的正常赛事包直接导不进来,属于生产事故。 +// +// 真正防「整个包驻留内存」的是 ParseMultipartForm 的 maxMemory(见 multipartMemoryBytes): +// 超出部分 Go 会自动落到临时文件,所以上限放宽不等于内存放大。 +// +// 它是 var 而不是 const,只为给测试留一个「临时调小再验拒绝路径」的口子 +// (见 SetMaxProjectImportBytesForTest);生产代码没有任何改动它的入口。 +var maxProjectImportBytes int64 = 512 << 20 + +// SetMaxProjectImportBytesForTest 临时调整赛事包上限,返回原值便于 defer 还原。 +// 仅供测试使用;调用方不要在 t.Parallel() 的用例里用它。 +func SetMaxProjectImportBytesForTest(v int64) int64 { + prev := maxProjectImportBytes + maxProjectImportBytes = v + return prev +} // exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。 func (s *server) exportRoutes(mux *http.ServeMux) { @@ -34,7 +54,7 @@ func (s *server) exportRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX)) // Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。 mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX)) - // 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。 + // 单位奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。 mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX)) mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX)) // teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。 @@ -135,13 +155,13 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any, }) } -// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。 +// exportMedalsXLSX 导出单位奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。 func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) { wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{}) if err != nil { return nil, internal(err) } - return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error { + return s.streamDownload(w, "medals.xlsx", "单位奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error { return export.WriteMedalDetailXLSX(out, wb) }) } @@ -249,7 +269,7 @@ type baseDataChange struct { // baseDataImportResult 是差异预览 / 导入落地的结果。 // 三类计数互不重叠:added + updated + unchanged = 表里有效数据行数; -// errorCount 是另计的被拦下的行(它们不写库)。 +// errorCount 是另计的被拦下的行(它们不写库),命中「已彻底删除」的单位也在这一档里。 type baseDataImportResult struct { DryRun bool `json:"dryRun"` Added int `json:"added"` @@ -270,7 +290,7 @@ type baseDataImportResult struct { // 表里没有的行一律不动——导入永远不做删除。 func (s *server) importBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) { r.Body = http.MaxBytesReader(w, r.Body, maxBaseDataImportBytes) - if err := r.ParseMultipartForm(16 << 20); err != nil { + if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil { return nil, badRequest("上传文件解析失败", err.Error()) } file, header, err := r.FormFile("file") @@ -325,6 +345,8 @@ type baseDataExistingTeam struct { color *string sortOrder int active bool + // deleted 表示这行是「已彻底删除」(teams.deleted_at 非空)的单位:库里有、界面上没有。 + deleted bool } type baseDataExistingSport struct { @@ -495,8 +517,10 @@ func (s *server) planBaseDataImport(ctx context.Context, tables []export.BaseDat func (p *baseDataPlanner) loadExisting() error { // 单位连「彻底删除」(deleted_at 非空)的一起读:编码有唯一索引,不认它就会在 INSERT 时撞索引。 // 更新不会碰 deleted_at,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。 + // deleted_at 也一并读出来:命中隐藏单位时要把它跳掉并明确告诉用户(见 planTeams), + // 只按「更新」走的话现场改完名字界面上仍然没有它,看起来像导入没生效。 teamRows, err := p.s.ev.QueryContext(p.ctx, ` - SELECT id, code, name, short_name, color, sort_order, active + SELECT id, code, name, short_name, color, sort_order, active, deleted_at IS NOT NULL FROM teams ORDER BY sort_order, code`) if err != nil { return internal(err) @@ -508,11 +532,12 @@ func (p *baseDataPlanner) loadExisting() error { code string shortName, color sql.NullString ) - var active int - if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active); err != nil { + var active, deleted int + if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active, &deleted); err != nil { return internal(err) } t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0 + t.deleted = deleted != 0 p.teams[code] = t } if err := teamRows.Err(); err != nil { @@ -623,6 +648,16 @@ func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) { if !ok { existing = baseDataExistingTeam{active: true} } + // 命中「已彻底删除」(deleted_at 非空)的单位:库里那行还在、界面上和导出件里都没有它。 + // 旧行为是照常走「更新 / 无变化」并真的写库,于是现场会看到「更新 1 条」却怎么也找不到这个单位 + // (它仍是隐藏状态),像是导入没生效。改成整行跳过 + 单独报一句原因与恢复入口。 + // 文案只指界面路径:这句话会直接显示给现场操作员,内部接口路径对他们没有意义。 + if ok && existing.deleted { + p.rowError(sheet, line, + "单位 %s 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先到「基础数据 → 参赛单位」勾选「显示已彻底删除的单位」后点「恢复」", + code) + continue + } plan := baseDataTeamPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder} if row.Has["active"] { active, ok := export.ParseBaseDataBool(row.Cells["active"], true) @@ -1400,9 +1435,17 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) return f.Name(), func() { _ = os.Remove(f.Name()) }, nil } +// multipartMemoryBytes 是所有 multipart 上传共用的**内存档位**(不是大小上限): +// 解析时最多在内存里留这么多,超出部分 Go 自动落到临时文件。 +// 防「整个包驻留内存」靠的是它,所以各入口的大小上限可以按内容类型各不相同。 +const multipartMemoryBytes = 16 << 20 + func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) { r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes) - if err := r.ParseMultipartForm(512 << 20); err != nil { + // 内存里只留 multipartMemoryBytes,超出的部分由 multipart 落在系统临时目录。 + // 注意别把 maxMemory 与上面的大小上限搞混:上限可以宽(赛事包带字体/图片), + // 内存档位必须窄。 + if err := r.ParseMultipartForm(multipartMemoryBytes); err != nil { return nil, badRequest("解析上传失败", nil) } file, _, err := r.FormFile("file") @@ -1496,6 +1539,19 @@ type backupRestoreResult struct { Warning string `json:"warning,omitempty"` } +// restoreMu 串行化「一键恢复」。 +// +// 恢复动的是数据目录里的库文件本身(关连接 → 整文件替换 → 触发重启), +// 同一次操作里没有任何一步能并发安全地跑两遍:两个管理员同时点(连点两次、两个窗口、 +// 两个人各按一次)会让两次替换交错——一个刚拍完安全副本、另一个已经换了文件, +// 回执还会各说各话(一个说已用 A 恢复、另一个说已用 B 恢复),而现场只看到「恢复了」。 +// +// 与备份侧 export.Service.mu 是同一套做法(同一时刻只让一个动库文件的操作在跑), +// 区别在这里是全局一把:要保护的是数据目录里的库文件,它不属于某一个 server 实例。 +// 后来者不排队静默执行——排队会让请求一直挂着,页面上的「正在重启」会变成假象, +// 所以用 TryLock 当场返回「正在恢复,请稍后」。 +var restoreMu sync.Mutex + // restoreBackupHandler 用备份文件恢复当前库(仅 admin)。 // // 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。 @@ -1510,6 +1566,12 @@ type backupRestoreResult struct { // → 关连接、替换文件 // → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本) func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) { + // 整段串行化(含参数校验):忙的时候直接回话,连文件都不碰。 + if !restoreMu.TryLock() { + return nil, conflict("正在恢复,请稍后:上一次恢复还在进行,等它重启完成、页面恢复后再试") + } + defer restoreMu.Unlock() + var in restoreBackupPayload if err := decodeJSON(r, &in); err != nil { return nil, err @@ -1629,10 +1691,14 @@ func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (a } else { base += ";服务端正在重启,请稍候刷新页面" } - notes := []string{"恢复前安全副本:" + safety.Name} + // 安全副本自己那份保留策略要固定说清楚:用户文档只承诺「不参与 20 份滚动清理」, + // 现场很容易理解成「这些副本会一直在」。实际上每类库只留最近 DefaultSafetyCopyKeep 份, + // 第 N 次恢复会把最早的删掉——不写死这句话,想退回三周前那个时间点时会扑空。 + // 但只在真清理了才说「已清理 N 份」:没删就说删了也是假话。 + notes := []string{fmt.Sprintf("恢复前安全副本:%s(每类库保留最近 %d 份,更旧的会被后续恢复清理)", + safety.Name, export.DefaultSafetyCopyKeep)} if pruned > 0 { - notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)", - pruned, export.DefaultSafetyCopyKeep)) + notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本", pruned)) } if kind == export.RestoreKindEvent { // 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。 diff --git a/server/internal/httpapi/export_handlers_internal_test.go b/server/internal/httpapi/export_handlers_internal_test.go index 4413815..75bf1b2 100644 --- a/server/internal/httpapi/export_handlers_internal_test.go +++ b/server/internal/httpapi/export_handlers_internal_test.go @@ -67,3 +67,25 @@ func TestExportProjectNameMessageIsActionable(t *testing.T) { t.Errorf("超长名称应提示长度上限,得到 %q", got) } } + +// D06:赛事包的上传**上限**故意高于另外两个批量导入,但**内存驻留**必须与它们同档。 +// +// 背景:名单 CSV 与基础数据包是纯文本/表格,32MB 上限够用;赛事包是唯一带二进制 +// 资源的包——赛事会标 / 大屏背景图 / 单位 logo / **字体**(单文件上限就有 20MB, +// 且允许多个)。所以上限不能按 32MB 卡(`两个字体 + 一张背景图` 的正常包会被拒), +// 真正要防的是「整个包驻留内存」,那由 ParseMultipartForm 的 maxMemory 决定: +// 超出 maxMemory 的部分 Go 会自动落到临时文件。 +// +// 这条用例钉两件事:①内存档位不许被单独放大;②上限不许被收回到与纯文本导入同档。 +func TestUploadMemoryCapStaysSmallAndProjectCapStaysGenerous(t *testing.T) { + if multipartMemoryBytes > 32<<20 { + t.Errorf("multipart 内存档位 = %d,超过 32MB 就得重新权衡内存占用", multipartMemoryBytes) + } + if maxProjectImportBytes < 128<<20 { + t.Errorf("赛事包上限 = %d,低于 128MB 会让带字体/图片的正常赛事包导不进来", maxProjectImportBytes) + } + if maxEntryImportBytes > 64<<20 || maxBaseDataImportBytes > 64<<20 { + t.Errorf("纯文本导入的上限不该超过 64MB:名单 = %d,基础数据包 = %d", + maxEntryImportBytes, maxBaseDataImportBytes) + } +} diff --git a/server/internal/httpapi/export_handlers_test.go b/server/internal/httpapi/export_handlers_test.go index 01246d5..f09ee8c 100644 --- a/server/internal/httpapi/export_handlers_test.go +++ b/server/internal/httpapi/export_handlers_test.go @@ -66,6 +66,55 @@ func (e *testEnv) postProjectImport(t *testing.T, client *http.Client, pack []by return resp.StatusCode, raw } +// D06:超过上限的赛事包必须在解析阶段就被挡住(400 + 中文提示), +// 超限赛事包必须在读进内存/走完导入流程之前就被拒绝。 +// +// ⚠️ 这里把上限**临时调小**再验拒绝路径:赛事包的上限故意放宽到 512MB(它是唯一带 +// 字体/图片等二进制资源的包,字体单文件上限就有 20MB),拿真的 512MB 请求体做用例 +// 太重;而「上限够宽」由 TestUploadMemoryCapStaysSmallAndProjectCapStaysGenerous +// 与下面那条 40MB 的用例钉住。 +func TestImportProjectZipRejectsOversizeUpload(t *testing.T) { + env := newTestEnvWith(t, func(o *httpapi.Options) { + o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test") + }) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + original := httpapi.SetMaxProjectImportBytesForTest(1 << 20) + defer httpapi.SetMaxProjectImportBytesForTest(original) + + // 2MB:内容不重要,超限本身就够了(MaxBytesReader 会在解析 multipart 时截断请求体)。 + oversize := bytes.Repeat([]byte("x"), 2<<20) + status, raw := env.postProjectImport(t, client, oversize, nil) + if status != http.StatusBadRequest { + t.Fatalf("超限赛事包应 400,得到 %d %s", status, raw) + } + if !strings.Contains(string(raw), "解析上传失败") { + t.Errorf("超限提示 = %s,期望「解析上传失败」", raw) + } +} + +// 40MB 的赛事包(超过纯文本导入那档 32MB)必须**过得了大小闸门**: +// 带两个字体 + 一张背景图的正常赛事包就是这个量级,按 32MB 卡会让现场导不进去。 +// 这里只断言「不再因体积被拒」,内容仍是假 zip,所以会走到后面的格式校验报错。 +func TestImportProjectZipAcceptsPackagesAboveTextImportLimit(t *testing.T) { + env := newTestEnvWith(t, func(o *httpapi.Options) { + o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test") + }) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + body := bytes.Repeat([]byte("x"), 40<<20) + status, raw := env.postProjectImport(t, client, body, nil) + if status == http.StatusBadRequest && strings.Contains(string(raw), "解析上传失败") { + t.Fatalf("40MB 赛事包不该因体积被拒(上限必须容得下带字体/图片的包):%s", raw) + } +} + // 覆盖导入正在运行的赛事项目会让运行库被 unlink 后继续写入,必须直接拒绝(P0-7)。 func TestImportProjectZipRejectsRunningProject(t *testing.T) { env := newTestEnvWith(t, func(o *httpapi.Options) { diff --git a/server/internal/httpapi/export_records_test.go b/server/internal/httpapi/export_records_test.go index 11efd40..b2504f0 100644 --- a/server/internal/httpapi/export_records_test.go +++ b/server/internal/httpapi/export_records_test.go @@ -53,7 +53,7 @@ func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) { } } -// guest 能下载团体奖牌明细;每块奖牌对应一条成绩。 +// guest 能下载单位奖牌明细;每块奖牌对应一条成绩。 func TestExportMedalsXLSXGuest(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) @@ -64,16 +64,16 @@ func TestExportMedalsXLSXGuest(t *testing.T) { if status != http.StatusOK { t.Fatalf("guest 下载奖牌明细应 200,得到 %d %s", status, body) } - assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "团体奖牌明细.xlsx") + assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "单位奖牌明细.xlsx") f := openWorkbook(t, body) defer func() { _ = f.Close() }() // 单位 B 只有银牌也有分表;两个单位都有奖牌 → 总表 + 2 张分表。 - want := []string{"团体奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"} + want := []string{"单位奖牌明细", "第一分公司奖牌明细", "第二分公司奖牌明细"} if got := f.GetSheetList(); !equalSheetList(got, want) { t.Fatalf("sheet 列表 = %v,期望 %v", got, want) } - master, err := f.GetRows("团体奖牌明细") + master, err := f.GetRows("单位奖牌明细") if err != nil { t.Fatalf("读取总表: %v", err) } @@ -94,16 +94,16 @@ func TestExportMedalsXLSXGuest(t *testing.T) { t.Errorf("分表小计 = %q", got) } - // 与团体奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。 + // 与单位奖牌榜一致:standings.xlsx 里单位 A 的金牌数 = 明细里金牌行数。 _, _, standingsBody := env.getRaw(t, client, "/api/export/standings.xlsx") sf := openWorkbook(t, standingsBody) defer func() { _ = sf.Close() }() - medalRows, err := sf.GetRows("团体奖牌榜") + medalRows, err := sf.GetRows("单位奖牌榜") if err != nil { - t.Fatalf("读取团体奖牌榜: %v", err) + t.Fatalf("读取单位奖牌榜: %v", err) } if got := masterCell(medalRows, 4, 3); got != "1" { - t.Errorf("团体奖牌榜首队金牌 = %q,期望 1", got) + t.Errorf("单位奖牌榜首队金牌 = %q,期望 1", got) } } diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index ee216df..bd42be6 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -70,11 +70,18 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) { } func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) { + u := middleware.User(r.Context()) if c, err := r.Cookie(auth.CookieSession); err == nil { if err := s.auth.Logout(r.Context(), c.Value); err != nil { return nil, internal(err) } } + // 登出是真删会话行(服务端强制下线),属于写操作,得和登录 / 改密一样留审计: + // 现场问「谁在什么时候把谁的会话撤了」时,只有这几条动作能对上。 + // 访客没有会话可撤,不记(否则 audit_logs 里会出现 user_id 为空的行)。 + if u.Role != auth.RoleGuest { + s.audit(r.Context(), "auth.logout", "user", u.ID, nil, map[string]any{"username": u.Username}) + } s.auth.ClearSessionCookie(w) s.auth.ClearCSRFCookie(w) return nil, nil @@ -105,6 +112,20 @@ func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) { if c, err := r.Cookie(auth.CookieCSRF); err == nil { csrf = c.Value } + if csrf == "" { + // 会话有效但 CSRF Cookie 不在了(浏览器清理、升级/换端口后残留、手工拷了会话 Cookie): + // 双提交方案下没有它,所有写操作会永久 403,而唯一的自愈路径是「退出后重新登录」—— + // 刷新页面走的还是这条路径,补不回来。这里就地签发一枚新 token 并下发, + // 会话与 token 的绑定关系没变(token 不落库、只做双提交比对),不影响任何安全口径。 + token, err := auth.NewCSRFToken() + if err != nil { + // crypto/rand 失败已经意味着整个进程不可信,但 /auth/me 是只读接口, + // 不能因为补票失败就把它变成 500:记下来,本次不补发。 + s.logger.Error("补发 CSRF Cookie 失败", "err", err) + } else { + csrf = token + } + } s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt) return out, nil } @@ -125,7 +146,10 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er if err := decodeJSON(r, &in); err != nil { return nil, err } - if len(in.NewPassword) < 8 { + // 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来, + // 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节—— + // bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。 + if utf8.RuneCountInString(in.NewPassword) < 8 { return nil, badRequest("新密码至少 8 位", nil) } if len(in.NewPassword) > auth.MaxPasswordBytes { @@ -563,13 +587,20 @@ func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (a return nil, err } var in profileDTO - if err := decodeJSON(r, &in); err != nil { + keys, err := decodeJSONPresence(r, &in) + if err != nil { return nil, err } before, err := s.getProfile(r.Context(), id) if err != nil { return nil, err } + // 漏传 lastPlaceZero 不能当成「用户把最后一名归零关掉了」:PUT 是全量覆盖语义, + // 而 Go 的 bool 零值就是 false——只想改个名字的调用方会把这一项静默关掉, + // 算分行为跟着变,界面上却看不出是谁改的(同族的 active 已按这个口径处理)。 + if !keys.has("lastPlaceZero") { + in.LastPlaceZero = before.LastPlaceZero + } updated, err := s.updateProfile(r.Context(), id, in) if err != nil { return nil, err @@ -755,7 +786,10 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a if err != nil { return nil, err } - s.audit(r.Context(), "entries.import", "entry", 0, nil, result) + // 审计动作一律以 entity_type 的单数族名打头:本族已有 entry.heat / entry.scratch / + // entry.restore / entry.remove,导入原先写成 entries.import 自成第二族, + // 现场按文档示例填 `entry.` 一筛会漏掉两条导入审计(清单 B01)。 + s.audit(r.Context(), "entry.import", "entry", 0, nil, result) return result, nil } @@ -789,7 +823,7 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a if err != nil { return nil, err } - s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result) + s.audit(r.Context(), "entry.import.resolve", "entry", 0, nil, result) return result, nil } @@ -987,6 +1021,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio // 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」 // 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。 + // 空串有专门含义:登记者没有证件号(个人行没填,或本来就是团体行),复用它一律按重号处理。 bibOwners := make(map[string]string) // 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示); // 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。 @@ -1066,11 +1101,29 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio } } + // 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份, + // 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。 + // 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。 + idCard := cell("id_card") + bib := cell("bib") + if bib != "" { + // 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。 + // 团体行没有「同一个人」这回事(它没有证件号可言),因此一律按重号处理。 + owner := idCard + if athleteName == "" { + owner = "" + } + if prev, seen := bibOwners[bib]; seen && (owner == "" || prev != owner) { + result.Skipped++ + result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib)) + continue + } + bibOwners[bib] = owner + } + // 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。 var athleteID *int64 if athleteName != "" { - idCard := cell("id_card") - bib := cell("bib") // 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。 if idCard == "" && bib == "" { result.Skipped++ @@ -1083,15 +1136,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数")) continue } - if bib != "" { - // 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。 - if owner, seen := bibOwners[bib]; seen && (idCard == "" || owner != idCard) { - result.Skipped++ - result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib)) - continue - } - bibOwners[bib] = idCard - } // 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。 // 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。 duplicate := false @@ -1141,7 +1185,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio TeamID: teamID, AthleteID: athleteID, MemberNames: splitNames(cell("member_names")), - Bib: nilIfEmpty(cell("bib")), + Bib: nilIfEmpty(bib), Seed: nilIfEmpty(cell("seed")), Heat: heat, Status: status, @@ -1153,6 +1197,13 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio case outcome.Created: result.Created++ affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID) + // 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员 + // 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。 + for _, moved := range outcome.MovedEntries { + result.Notices = append(result.Notices, fmt.Sprintf( + "第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入", + line, moved.Name, moved.OldTeamCode, moved.NewTeamCode)) + } default: result.Updated++ if outcome.StatusChanged { diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index d2bd872..a005608 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" @@ -554,7 +555,7 @@ func TestImportResolveKeepExisting(t *testing.T) { var audit int if err := env.ev.QueryRow( - `SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil { + `SELECT COUNT(*) FROM audit_logs WHERE action = 'entry.import.resolve'`).Scan(&audit); err != nil { t.Fatalf("统计审计: %v", err) } if audit == 0 { @@ -598,6 +599,61 @@ func TestImportResolveKeepNew(t *testing.T) { } } +// B01:报名这一族的审计动作必须共用单数前缀 entry.(导入、批次、批量退赛/恢复/移出都算)。 +// 用例真发三个写请求,再走日志页同一条查询(`/api/audit-logs?action=entry.`,子串模糊) +// 断言三条都在 —— 只看字符串常量的话,改回 entries.import 这类回归照样能绿。 +func TestEntryAuditActionsShareOnePrefix(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + // ① 报名表导入(新增一个人 + 一条报名)。 + body := importHeader + "\nM100,男子,A,前缀用例选手,110101199001019999,男,,X900,,registered,\n" + if status, env2 := env.postImport(t, client, "/api/entries/import", body, ""); status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + // ② 批量设置批次。 + if status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA}, "heat": 3, + }, true); status != http.StatusOK || !env2.OK { + t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error) + } + // ③ 批量退赛。 + if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "scratch", "ids": []int64{ids.entryB}, "reason": "前缀用例", + }, true); status != http.StatusOK || !env2.OK { + t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error) + } + + want := []string{"entry.import", "entry.heat", "entry.scratch"} + // /api/audit-logs 是 admin-only;审计在响应之后落库,轮询到齐再断言。 + env.createUser("admin", "admin-pass", "admin") + adminClient, _ := env.client() + env.login(adminClient, "admin", "admin-pass") + + found := map[string]bool{} + for i := 0; i < 100 && len(found) < len(want); i++ { + _, env2 := env.do(adminClient, http.MethodGet, "/api/audit-logs?action=entry.&size=200", nil, false) + items, _ := decodeData[map[string]any](t, env2)["items"].([]any) + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + continue + } + action, _ := item["action"].(string) + if slices.Contains(want, action) { + found[action] = true + } + } + if len(found) < len(want) { + time.Sleep(10 * time.Millisecond) + } + } + for _, action := range want { + if !found[action] { + t.Fatalf("按 entry. 前缀应能筛到 %s,实际只筛到 %+v", action, found) + } + } +} + // decisions 非法时返回 400。 func TestImportResolveRejectsBadDecisions(t *testing.T) { env, _, client := recorderWithSeed(t) diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index ded6de9..7d26af9 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -228,10 +228,6 @@ func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Reques // ---------- 大屏控制与运维 ---------- -// cmdAnnounceClear 是清空当前公告的指令;实时层的协议清单(realtime.ValidDisplayAction)还没登记它, -// 因此转发前要先看实时层认不认,见 displayCommandHandler。 -const cmdAnnounceClear = "announce_clear" - // announceFromPayload 从 announce 指令的 payload 里取公告正文与全屏时长; // payload 由 JSON 解出,缺失的字段按零值处理,由落库侧补默认时长。 func announceFromPayload(payload any) (string, int) { @@ -260,9 +256,9 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( return nil, badRequest("action 必填", nil) } matched := 0 - // 清空公告必须落库,即使实时层尚不认这个 action(转发会被 NewDisplayCommand 拒收), - // 否则「撤销公告」会整体失败;协议登记后这里的转发自动生效。 - if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) { + // 没接实时层(s.display == nil)时不转发,但下面的公告落库照旧:公告能被后连上来的 + // 大屏读到靠的是 settings + change_log,与这一次广播无关。 + if s.display != nil { n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets) if err != nil { // 未知 action 是请求体入参不合法,按 400 报,现场才看得懂「改 action」而不是「服务端异常」。 @@ -281,7 +277,8 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( } } // 公告要落库:只转发不落库的话,全屏推送结束(seconds 秒)后底栏会回落到「暂无公告」, - // 新连上的大屏也拿不到当前公告。 + // 新连上的大屏也拿不到当前公告。清空公告走同一份持久值(写空串), + // 否则大屏重连后会把已经撤掉的公告再显示一遍。 actorID := middlewareUserID(r) switch in.Action { case realtime.CmdAnnounce: @@ -289,7 +286,7 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) ( if err := s.saveAnnouncement(r.Context(), text, seconds, actorID); err != nil { return nil, err } - case cmdAnnounceClear: + case realtime.CmdAnnounceClear: if err := s.clearAnnouncement(r.Context(), actorID); err != nil { return nil, err } diff --git a/server/internal/httpapi/handlers_revisions.go b/server/internal/httpapi/handlers_revisions.go index c113e33..8f2e3d9 100644 --- a/server/internal/httpapi/handlers_revisions.go +++ b/server/internal/httpapi/handlers_revisions.go @@ -222,8 +222,8 @@ func (s *server) getRevisionDetail(ctx context.Context, id int64) (revisionDetai }, nil } -// parentSnapshot 返回父节点快照:优先用显式 parent_id; -// 现有写入逻辑不写 parent_id,因此退化为「同小项上一条记录」,与文档「每个小项一条主线」一致。 +// parentSnapshot 返回父节点快照:优先用显式 parent_id(recordRevision 写入的「同小项上一条版本」); +// parent_id 为 NULL(每个小项的第一条版本、以及本轮改动之前的老数据)时,退化为按 id 找「同小项上一条记录」。 func (s *server) parentSnapshot(ctx context.Context, row revisionRow) ([]resultDTO, bool, error) { var ( raw string diff --git a/server/internal/httpapi/handlers_users.go b/server/internal/httpapi/handlers_users.go index b437074..88d985a 100644 --- a/server/internal/httpapi/handlers_users.go +++ b/server/internal/httpapi/handlers_users.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "unicode/utf8" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" ) @@ -138,7 +139,10 @@ func (s *server) createUser(ctx context.Context, in createUserInput) (userListIt if err != nil { return userListItemDTO{}, err } - if len(in.Password) < 8 { + // 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来, + // 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节—— + // bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。口径与改密接口一致(见 handlers_config.go)。 + if utf8.RuneCountInString(in.Password) < 8 { return userListItemDTO{}, badRequest("password 至少 8 位", nil) } if len(in.Password) > auth.MaxPasswordBytes { @@ -228,7 +232,9 @@ func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPa return resetPasswordResultDTO{}, internal(err) } generated = true - } else if len(password) < 8 { + } else if utf8.RuneCountInString(password) < 8 { + // 与建号、改密同一口径:下限按字符(rune)算——3 个汉字是 9 字节, + // 按字节判会把前端(按字符数)拒绝的口令放进来;上限仍按字节(bcrypt 只吃前 72 字节)。 return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil) } else if len(password) > auth.MaxPasswordBytes { return resetPasswordResultDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil) diff --git a/server/internal/httpapi/handlers_users_test.go b/server/internal/httpapi/handlers_users_test.go index 53ccafc..5deee05 100644 --- a/server/internal/httpapi/handlers_users_test.go +++ b/server/internal/httpapi/handlers_users_test.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" "testing" + "unicode/utf8" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" ) @@ -42,3 +43,74 @@ func TestUserPasswordLengthLimits(t *testing.T) { t.Fatalf("超长密码重置应 400,得到 %d %+v", status, env2.Error) } } + +// 「至少 8 位」按字符(rune)算,不是按字节:3 个汉字是 9 字节, +// 按字节判会把前端(按字符数)拒绝的口令放进来,同一口令界面拒、接口收。 +// 上限仍按字节——bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。 +// 口径必须与改密接口(handlers_config.go)完全一致(P2 A37 同源缺口)。 +func TestUserPasswordLengthCountsRunes(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + // 3 个汉字 + 5 个字母 = 8 字符 / 14 字节:字数够,必须放行。 + eightRunes := "密码呀abcde" + if utf8.RuneCountInString(eightRunes) != 8 || len(eightRunes) != 14 { + t.Fatalf("用例构造错误:应 8 字符 / 14 字节,得到 %d 字符 / %d 字节", + utf8.RuneCountInString(eightRunes), len(eightRunes)) + } + // 7 个汉字 = 7 字符 / 21 字节:字数不够,必须拒(按字节判的老口径会放行)。 + sevenRunes := strings.Repeat("汉", 7) + // 25 个汉字 = 25 字符 / 75 字节:字数够但超 bcrypt 上限,必须拒。 + tooManyBytes := strings.Repeat("汉", 25) + + status, env2 := env.do(client, http.MethodPost, "/api/users", map[string]any{ + "username": "u-runes", "password": eightRunes, "role": "recorder", + }, true) + if status != http.StatusOK { + t.Fatalf("8 字符(14 字节)的密码建号应 200,得到 %d %+v", status, env2.Error) + } + created := decodeData[map[string]any](t, env2) + id := int64(created["id"].(float64)) + if id == 0 { + t.Fatalf("建号响应缺少 id: %+v", created) + } + // 建号的密码能直接登录,说明服务端真的按这一串收下了(没有被截断成别的口令)。 + // 用新 client:带会话再登录要过 CSRF,而这里要的正是「换个人重新登」。 + fresh, _ := env.client() + env.login(fresh, "u-runes", eightRunes) + + status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{ + "username": "u-short", "password": sevenRunes, "role": "recorder", + }, true) + if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "password 至少 8 位" { + t.Fatalf("7 个汉字是 7 字符,建号应 400「password 至少 8 位」,得到 %d %+v", status, env2.Error) + } + + status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{ + "username": "u-long", "password": tooManyBytes, "role": "recorder", + }, true) + if status != http.StatusBadRequest || env2.Error == nil || + env2.Error.Message != fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes) { + t.Fatalf("25 个汉字是 75 字节,建号应 400 字节上限,得到 %d %+v", status, env2.Error) + } + + // 重置密码与建号同口径:8 字符放行、7 字符拒绝。 + status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id), + map[string]any{"password": eightRunes}, true) + if status != http.StatusOK { + t.Fatalf("8 字符(14 字节)的密码重置应 200,得到 %d %+v", status, env2.Error) + } + status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id), + map[string]any{"password": sevenRunes}, true) + if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "password 至少 8 位" { + t.Fatalf("7 个汉字是 7 字符,重置应 400「password 至少 8 位」,得到 %d %+v", status, env2.Error) + } + status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", id), + map[string]any{"password": tooManyBytes}, true) + if status != http.StatusBadRequest || env2.Error == nil || + env2.Error.Message != fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes) { + t.Fatalf("25 个汉字是 75 字节,重置应 400 字节上限,得到 %d %+v", status, env2.Error) + } +} diff --git a/server/internal/httpapi/log_files_test.go b/server/internal/httpapi/log_files_test.go index ed6df94..bee895c 100644 --- a/server/internal/httpapi/log_files_test.go +++ b/server/internal/httpapi/log_files_test.go @@ -104,7 +104,7 @@ func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) { "请求被拦截", "无权访问该资源", "请先登录", - "CSRF 校验失败,请刷新页面重试", + "CSRF 校验失败,请退出后重新登录", "请先修改初始密码", "internal/middleware/middleware.go:", } { diff --git a/server/internal/httpapi/p2_fixes_test.go b/server/internal/httpapi/p2_fixes_test.go new file mode 100644 index 0000000..bb12bb5 --- /dev/null +++ b/server/internal/httpapi/p2_fixes_test.go @@ -0,0 +1,343 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" +) + +// cookieValue 读 jar 里某个 Cookie 的当前值;没有则返回空串。 +func (e *testEnv) cookieValue(t *testing.T, client *http.Client, name string) string { + t.Helper() + u, err := url.Parse(e.server.URL) + if err != nil { + t.Fatalf("解析测试服务地址: %v", err) + } + for _, c := range client.Jar.Cookies(u) { + if c.Name == name { + return c.Value + } + } + return "" +} + +// ---------- A38:/auth/me 在 CSRF Cookie 丢失时补发 ---------- + +// 会话有效但这台机器没有 CSRF Cookie 时,写操作会永久 403,而刷新页面走的还是 /auth/me, +// 补不回 Cookie。修法是就地签发一枚新 token 并下发——会话本身不轮换。 +func TestMeReissuesMissingCSRFCookie(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, jar := env.client() + env.login(client, "admin", "admin-pass") + session := env.cookieValue(t, client, auth.CookieSession) + + // 模拟丢票:浏览器清理、换端口 / 换域名后残留、手工拷了会话 Cookie。 + u, _ := url.Parse(env.server.URL) + jar.SetCookies(u, []*http.Cookie{{Name: auth.CookieCSRF, Value: "", MaxAge: -1, Path: "/"}}) + if got := env.csrfToken(client); got != "" { + t.Fatalf("前置条件:此时 jar 里不该还有 CSRF Cookie,得到 %q", got) + } + + status, header, body := env.getRaw(t, client, "/api/auth/me") + if status != http.StatusOK { + t.Fatalf("me 应 200,得到 %d:%s", status, body) + } + var setCookie bool + for _, v := range header.Values("Set-Cookie") { + if strings.Contains(v, auth.CookieCSRF+"=") { + setCookie = true + } + } + if !setCookie { + t.Fatalf("/auth/me 必须补发 CSRF Cookie,响应头是 %v", header.Values("Set-Cookie")) + } + if env.csrfToken(client) == "" { + t.Fatal("补发的 CSRF Cookie 应落进 jar 且有值") + } + // 补的是票,不是新登录:会话 Cookie 不能被换掉。 + if got := env.cookieValue(t, client, auth.CookieSession); got != session { + t.Fatalf("补票不该轮换会话:%s → %s", session, got) + } + // 新票立即可用:带上它写就通过(这正是「能看不能写」的自愈路径)。 + if status, resp := env.do(client, http.MethodPut, "/api/settings", + map[string]any{"event": map[string]any{"name": "补票后写入"}}, true); status != http.StatusOK || !resp.OK { + t.Fatalf("持补发票的写请求应通过,得到 %d %+v", status, resp.Error) + } +} + +// ---------- A37:密码下限按字符判 ---------- + +// 「至少 8 位」是按字符说的,前端也按字符数判;按字节判会放行 7 个汉字这种 +// 界面明确拒绝的口令,同一口令两个口径。上限仍按字节(bcrypt 只吃 72 字节)。 +func TestChangePasswordLengthCountsRunes(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + // 7 个汉字 = 21 字节:按字节判会放行,必须按字符拒掉。 + status, body := env.do(client, http.MethodPost, "/api/auth/password", + map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七"}, true) + if status != http.StatusBadRequest || body.Error == nil || body.Error.Message != "新密码至少 8 位" { + t.Fatalf("7 个字符的口令应 400「新密码至少 8 位」,得到 %d %+v", status, body.Error) + } + + // 25 个汉字 = 75 字节:字符数够,但超过 bcrypt 的 72 字节上限,仍要拒。 + long := strings.Repeat("汉", 25) + status, body = env.do(client, http.MethodPost, "/api/auth/password", + map[string]any{"currentPassword": "admin-pass", "newPassword": long}, true) + if status != http.StatusBadRequest || body.Error == nil || + body.Error.Message != fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes) { + t.Fatalf("超 72 字节应 400 字节上限,得到 %d %+v", status, body.Error) + } + + // 失败的改密不该动会话:紧接着还能再改一次。 + // 8 个汉字 = 24 字节:字符与字节都合法,收下。 + status, body = env.do(client, http.MethodPost, "/api/auth/password", + map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七八"}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("8 个字符的口令应被接受,得到 %d %+v", status, body.Error) + } + var hash string + if err := env.sys.QueryRow(`SELECT password_hash FROM users WHERE username = 'admin'`).Scan(&hash); err != nil { + t.Fatalf("读密码哈希: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("一二三四五六七八")); err != nil { + t.Fatalf("落库的应是这串汉字口令: %v", err) + } +} + +// ---------- A05:漏传 lastPlaceZero 不能静默变成 false ---------- + +func TestUpdateProfileKeepsLastPlaceZeroWhenOmitted(t *testing.T) { + env := newTestEnv(t) + client := recorderClient(t, env) + + status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ + "code": "P-LPZ-PUT", "name": "最后一名归零", "lastPlaceZero": true, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("建模板应 200,得到 %d %+v", status, resp.Error) + } + id := int64(decodeData[map[string]any](t, resp)["id"].(float64)) + path := fmt.Sprintf("/api/scoring-profiles/%d", id) + + // 只想改名字的 PUT:漏传 lastPlaceZero 必须沿用库里的值,而不是被 bool 零值写成 false。 + status, resp = env.do(client, http.MethodPut, path, + map[string]any{"code": "P-LPZ-PUT", "name": "改名不改开关"}, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("改模板应 200,得到 %d %+v", status, resp.Error) + } + if got := profileLastPlaceZero(t, env, id); got != 1 { + t.Fatalf("漏传 lastPlaceZero 不该把它写成 0,库里是 %d", got) + } + + // 显式传 false 才是真的关掉。 + if status, resp = env.do(client, http.MethodPut, path, map[string]any{ + "code": "P-LPZ-PUT", "name": "显式关掉", "lastPlaceZero": false, + }, true); status != http.StatusOK || !resp.OK { + t.Fatalf("显式 false 应 200,得到 %d %+v", status, resp.Error) + } + if got := profileLastPlaceZero(t, env, id); got != 0 { + t.Fatalf("显式 false 应落库为 0,库里是 %d", got) + } + + // 显式传 true 再打开。 + if status, resp = env.do(client, http.MethodPut, path, map[string]any{ + "code": "P-LPZ-PUT", "name": "显式打开", "lastPlaceZero": true, + }, true); status != http.StatusOK || !resp.OK { + t.Fatalf("显式 true 应 200,得到 %d %+v", status, resp.Error) + } + if got := profileLastPlaceZero(t, env, id); got != 1 { + t.Fatalf("显式 true 应落库为 1,库里是 %d", got) + } +} + +func profileLastPlaceZero(t *testing.T, env *testEnv, id int64) int { + t.Helper() + var v int + if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE id = ?`, id).Scan(&v); err != nil { + t.Fatalf("读 last_place_zero: %v", err) + } + return v +} + +// ---------- A11:号码布查重必须覆盖团体行 ---------- + +// 团体行(athlete_name 为空)原先完全不参与号码布查重:填了号码布既不登记也不检查, +// 同一张表里两个团体行共用一个号码布会静默落库。 +func TestImportChecksBibOnTeamRows(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + { + name: "两个团体行共用一个号码布", + body: importHeader + "\n" + + "M100,男子,A,,,男,,B3001,,registered,张三/李四\n" + + "M100,男子,B,,,男,,B3001,,registered,王五/赵六\n", + }, + { + name: "团体行与个人行共用一个号码布(团体行在前)", + body: importHeader + "\n" + + "M100,男子,A,,,男,,B3002,,registered,张三/李四\n" + + "M100,男子,B,张三,,男,,B3002,,registered,\n", + }, + { + name: "团体行与个人行共用一个号码布(个人行在前)", + body: importHeader + "\n" + + "M100,男子,A,张三,,男,,B3003,,registered,\n" + + "M100,男子,B,,,男,,B3003,,registered,王五/赵六\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + env, _, client := recorderWithSeed(t) + status, resp := env.postImport(t, client, "/api/entries/import", tc.body, "") + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + result := decodeData[map[string]any](t, resp) + if result["created"] != float64(1) || result["skipped"] != float64(1) { + t.Fatalf("首行应落库、重号行应跳过: %+v", result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") { + t.Fatalf("应报号码布重复,得到 %+v", errs) + } + }) + } + + // 号码布不同的团体行照常导入,且号码布真的落进报名行。 + env, _, client := recorderWithSeed(t) + body := importHeader + "\n" + + "M100,男子,A,,,男,,B3101,,registered,张三/李四\n" + + "M100,男子,B,,,男,,B3102,,registered,王五/赵六\n" + status, resp := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !resp.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) + } + if result := decodeData[map[string]any](t, resp); result["created"] != float64(2) || result["skipped"] != float64(0) { + t.Fatalf("号码布不重复的团体行都该落库: %+v", result) + } + var n int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries WHERE bib IN ('B3101', 'B3102')`).Scan(&n); err != nil { + t.Fatalf("统计报名号码布: %v", err) + } + if n != 2 { + t.Fatalf("团体行的号码布应写进报名,得到 %d 行", n) + } +} + +// ---------- C14:登出要写审计 ---------- + +func TestLogoutWritesAudit(t *testing.T) { + env := newTestEnv(t) + userID := env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK { + t.Fatalf("登出应 200,得到 %d", status) + } + var actorID sql.NullInt64 + if err := env.ev.QueryRow( + `SELECT user_id FROM audit_logs WHERE action = 'auth.logout' ORDER BY id DESC LIMIT 1`).Scan(&actorID); err != nil { + t.Fatalf("登出会真删会话行,必须写审计: %v", err) + } + if !actorID.Valid || actorID.Int64 != userID { + t.Fatalf("审计应记在真实用户 %d 上,得到 %+v", userID, actorID) + } + + // 访客没有会话可撤:不记(否则 audit_logs 里会出现 user_id 为空的行)。 + guest, _ := env.client() + if status, _ := env.do(guest, http.MethodPost, "/api/auth/logout", nil, false); status != http.StatusOK { + t.Fatalf("访客登出应 200,得到 %d", status) + } + var n int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout'`).Scan(&n); err != nil { + t.Fatalf("统计登出审计: %v", err) + } + if n != 1 { + t.Fatalf("访客登出不该写审计,得到 %d 条", n) + } +} + +// ---------- A39:反代改写 Host 时的登录(端到端) ---------- + +// 现场场景:nginx 把 Host 改写成回源地址(127.0.0.1:port),浏览器带的 Origin 是外部域名。 +// 只要直连对端在可信代理白名单里、并透传了原 Host,登录就该照常成功; +// 反过来,没配可信代理时服务端不采信任何代理头——同源校验不放水。 +func TestLoginBehindTrustedProxyWithForwardedHost(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + + forwarded := map[string]string{ + "Origin": "http://scoreboard.local", + "X-Forwarded-Host": "scoreboard.local", + } + + // 没配可信代理:r.Host(回源地址)与 Origin 不一致 → 403,提示指向反代配置。 + status, resp := env.doH(client, http.MethodPost, "/api/auth/login", + map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded) + if status != http.StatusForbidden || resp.Error == nil || resp.Error.Code != "FORBIDDEN" { + t.Fatalf("未配置可信代理时应 403(不采信代理头),得到 %d %+v", status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "Host") { + t.Fatalf("提示应说明 Host 与 Origin 不一致,得到 %q", resp.Error.Message) + } + + // 把直连对端登记为可信代理:代理透传的原 Host 与 Origin 一致 → 登录放行。 + if err := middleware.SetTrustedProxies([]string{"127.0.0.1"}); err != nil { + t.Fatalf("配置可信代理: %v", err) + } + t.Cleanup(func() { _ = middleware.SetTrustedProxies(nil) }) + + status, resp = env.doH(client, http.MethodPost, "/api/auth/login", + map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded) + if status != http.StatusOK || !resp.OK { + t.Fatalf("可信代理透传原 Host 时登录应成功,得到 %d %+v", status, resp.Error) + } + if env.cookieValue(t, client, auth.CookieSession) == "" { + t.Fatal("登录成功应下发会话 Cookie") + } +} + +// ---------- C15:登录限流不再是死配置 ---------- + +// 迁移 0005 种下的 security.login_rate_per_minute 必须真的被读(此前全仓无人引用)。 +func TestLoginRateSettingFromMigrationIsRead(t *testing.T) { + env := newTestEnv(t) + + var seeded string + if err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, auth.LoginRateKey).Scan(&seeded); err != nil { + t.Fatalf("迁移应种下 %s: %v", auth.LoginRateKey, err) + } + if seeded != strconv.Itoa(auth.DefaultLoginPerMinute) { + t.Fatalf("迁移种下的值应与内置默认一致,得到 %q", seeded) + } + + // 「重启后」(新起一个 Service)读库里的值:改成 2 之后第 3 次登录就该被限流。 + if _, err := env.sys.Exec(`UPDATE settings SET value = '2' WHERE key = ?`, auth.LoginRateKey); err != nil { + t.Fatalf("改登录限流设置: %v", err) + } + svc := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost}) + for i := 0; i < 2; i++ { + if !svc.AllowLogin("127.0.0.1") { + t.Fatalf("第 %d 次应放行", i+1) + } + } + if svc.AllowLogin("127.0.0.1") { + t.Fatal("第 3 次应被限流——库里的登录限流设置没有被读到") + } +} diff --git a/server/internal/httpapi/ratelimit_intercept_log_test.go b/server/internal/httpapi/ratelimit_intercept_log_test.go new file mode 100644 index 0000000..2d97a43 --- /dev/null +++ b/server/internal/httpapi/ratelimit_intercept_log_test.go @@ -0,0 +1,109 @@ +package httpapi_test + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/logging" +) + +// C21:写限流的 429 是「中间件拦下的」——它不经 httpapi 的 writeAPIError, +// 走的是 middleware.intercept。此前只有人肉验证,没有用例盯着它是否真的落进 +// interceptions/:拦截点写错、类别标记漏传、乃至 429 干脆不记日志,都不会有人发现。 +// +// 用真实日志装配(logging.New 写临时目录)而不是内存缓冲:要验的正是「落哪个目录」。 +func TestRateLimitInterceptionLandsInInterceptions(t *testing.T) { + logDir := t.TempDir() + handler, closer := logging.New(logging.Options{ + Dir: logDir, Level: slog.LevelInfo, Console: io.Discard, MaxSizeMB: 20, KeepFiles: 10, + }) + t.Cleanup(func() { _ = closer.Close() }) + logger := slog.New(handler) + + env := newTestEnvWith(t, func(o *httpapi.Options) { + o.Logger = logger + o.Auth = auth.New(o.System, auth.Options{ + Logger: logger, + BcryptCost: bcrypt.MinCost, + WritePerMinute: 2, + }) + }) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + for i := 1; i <= 2; i++ { + if status, body := env.do(client, http.MethodPost, "/api/teams", + map[string]any{"code": fmt.Sprintf("T%d", i), "name": "阈值内的单位"}, true); status != http.StatusOK { + t.Fatalf("第 %d 次写应在阈值内放行,得到 %d %+v", i, status, body.Error) + } + } + status, body := env.do(client, http.MethodPost, "/api/teams", + map[string]any{"code": "T9", "name": "超限单位"}, true) + if status != http.StatusTooManyRequests { + t.Fatalf("超出阈值应 429,得到 %d %+v", status, body.Error) + } + + inter := readHTTPAPILog(t, logDir, logging.DirInterceptions) + // WARN 是默认 info 级别下现场能看到的那行:为什么没让他写 + 是谁拦的 + 什么码。 + for _, want := range []string{ + `"level":"WARN"`, + "请求被拦截", + "操作过于频繁,请稍后再试", + `"code":"RATE_LIMITED"`, + `"actor":"recorder#`, + "internal/middleware/middleware.go:", + "RateLimitWrite", + } { + if !strings.Contains(inter, want) { + t.Fatalf("429 的拦截日志应含 %q,得到:\n%s", want, inter) + } + } + // DEBUG 那行补上下文:现场要拿它判断是哪个账号、哪条路径、哪台机器在刷。 + for _, want := range []string{`"level":"DEBUG"`, "拦截上下文", `"path":"/api/teams"`, `"code":"RATE_LIMITED"`} { + if !strings.Contains(inter, want) { + t.Fatalf("429 的拦截上下文应含 %q,得到:\n%s", want, inter) + } + } + // 429 不该被记成 5xx:它是一次有意为之的拦截,不是服务端异常。 + if strings.Contains(inter, `"level":"ERROR"`) { + t.Fatalf("429 不该出现 ERROR 级日志,得到:\n%s", inter) + } + + // 拦截类不能落 operations/:两类混在一起,「今天被拦过几次、为什么」就查不出来了。 + ops := readHTTPAPILog(t, logDir, logging.DirOperations) + for _, unwanted := range []string{"请求被拦截", "拦截上下文"} { + if strings.Contains(ops, unwanted) { + t.Fatalf("operations/ 不该含 %q,得到:\n%s", unwanted, ops) + } + } + // 反向对照:放行的那两次写仍要落 operations/,别把两类日志整体搞反。 + if !strings.Contains(ops, "操作已放行") { + t.Fatalf("放行的写操作应落 operations/,得到:\n%s", ops) + } +} + +// readHTTPAPILog 读某类日志今天的文件内容;文件不存在返回空串。 +func readHTTPAPILog(t *testing.T, dir, category string) string { + t.Helper() + path := filepath.Join(dir, category, category+"-"+time.Now().Format("20060102")+".log") + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatalf("读日志 %s: %v", path, err) + } + return string(raw) +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index 76ab67e..ca54c00 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -222,7 +222,10 @@ func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) { } func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) { - if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + // 编码与名字落库前先裁掉首尾空白:编码是导入按 team_code 匹配的键, + // 「 A 」与「A」肉眼一样却互不相等(UNIQUE 按字节比),现场只会看到「单位不存在」。 + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.Code == "" || in.Name == "" { return teamDTO{}, badRequest("code 与 name 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) @@ -260,6 +263,12 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) { } func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) { + // 更新与新建同口径:必填校验 + 裁空白。漏了这一段,一次 PUT 就能把单位的 code/name + // 清成空串或纯空格(编码是导入的匹配键,清掉之后导入再也认不出这个单位)。 + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.Code == "" || in.Name == "" { + return teamDTO{}, badRequest("code 与 name 必填", nil) + } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamDTO{}, internal(err) @@ -441,7 +450,8 @@ func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) { } // isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。 -// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。 +// 与 isUniqueViolation 的区别不是「谁更严」,而是**按索引名认人**:这里只看错误串里有没有 +// "athletes.id_card",好把证件号冲突的文案与号码布冲突(isBibConflict)及其它唯一冲突分开。 func isIDCardConflict(err error) bool { if err == nil { return false @@ -639,6 +649,8 @@ func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, erro defer func() { _ = tx.Rollback() }() // 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。 + // 这个局部 scope 与下面的包级 purgeScope 完全同构(P2-C24c)——合并不改任何行为, + // 但属于代码改动,本轮只按注释记一笔,留给下一轮统一。 type scope struct{ eventID, categoryID int64 } scopeRows, err := tx.QueryContext(ctx, `SELECT DISTINCT e.event_id, e.category_id @@ -753,6 +765,17 @@ const ( athleteBatchPurge = "purge" ) +// maxAthleteBatch 是单次批量动作的人数上限,与 maxEntryBatch(报名项,entries_batch.go)同量级: +// 前端一次勾选几十人是常态,500 足够覆盖「一个单位全选」,再大说明是脚本误用或误粘贴。 +// +// 不直接复用 maxEntryBatch:那个常量从名字到文案都是「报名项」,拿它去限「人」会把两类对象说混, +// 操作员照着提示去报名表里找问题只会更糊涂。数值刻意保持一致,同族的批量接口用同一把尺子, +// 前端不必为每个接口记不同的分批阈值(上限值本身的分批依据与 entries_batch.go 的那条注释同源)。 +// +// 没有这层上限时,几万条 id 会先被拼成一条 IN (...) 的巨型 SQL:撞穿 SQLite 的变量上限, +// 报出来是 500,而且单连接库会被这条 SQL 长时间占住(审查 D03 实测 4 万条即触发)。 +const maxAthleteBatch = 500 + // athleteBatchItem 是批量动作里逐条的结果。 // // 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试, @@ -852,6 +875,11 @@ func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int6 if len(ids) == 0 { return athleteBatchResult{}, badRequest("ids 不能为空", nil) } + // 上限拦在「读库 / 开事务」之前,与 entries_batch.go:73 同一个位置口径: + // 超限的请求不该先查一遍 id 是否存在、再拼出一条巨 SQL 去占住单连接。 + if len(ids) > maxAthleteBatch { + return athleteBatchResult{}, badRequest(fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", maxAthleteBatch), nil) + } if action == athleteBatchPurge { return s.batchPurgeAthletes(ctx, ids, dryRun) } @@ -1109,7 +1137,8 @@ func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) { } func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) { - if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.Code == "" || in.Name == "" { return sportDTO{}, badRequest("code 与 name 必填", nil) } res, err := s.ev.ExecContext(ctx, @@ -1119,6 +1148,12 @@ func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) if isUniqueViolation(err) { return sportDTO{}, conflict("大项编码已存在") } + // default_profile_id 指向 scoring_profiles(id)(迁移 0004):指到不存在的模板时, + // 不认这个外键错就报 500「服务端异常」,而同一类错在 events.sport_id、channels.profile_id + // 上都是 400。按 badRequest 报出来,现场才知道该去改哪个字段。 + if isForeignKeyViolation(err) { + return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID) + } if err != nil { return sportDTO{}, internal(err) } @@ -1130,6 +1165,11 @@ func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) } func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) { + // 更新与新建同口径:必填校验 + 裁空白,否则一次 PUT 就能把大项编码清成空串。 + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.Code == "" || in.Name == "" { + return sportDTO{}, badRequest("code 与 name 必填", nil) + } res, err := s.ev.ExecContext(ctx, `UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ? WHERE id = ?`, @@ -1137,6 +1177,10 @@ func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportD if isUniqueViolation(err) { return sportDTO{}, conflict("大项编码已存在") } + // 同新建:改到一个不存在的模板 id 上是 400(字段名 + 原值),不是 500。 + if isForeignKeyViolation(err) { + return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID) + } if err != nil { return sportDTO{}, internal(err) } @@ -1247,7 +1291,8 @@ func validateAwardRanks(awardRanks int) error { } func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) { - if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.Code == "" || in.Name == "" { return profileDTO{}, badRequest("code 与 name 必填", nil) } tieMode, err := normalizeTieMode(in.TieMode) @@ -1300,6 +1345,13 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, } func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) { + // 与新建同口径。这里不是「顺手补个校验」:清空 code 的模板会让 admin 的 + // GET /api/rules/export 与 project.zip 永久 500(rules/validate.go 仍要求 code 非空), + // 而 PUT 是 recorder 档——低级角色能把管理员的导出打成不可用,且没有任何提示。 + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.Code == "" || in.Name == "" { + return profileDTO{}, badRequest("code 与 name 必填", nil) + } tieMode, err := normalizeTieMode(in.TieMode) if err != nil { return profileDTO{}, err @@ -1352,10 +1404,18 @@ func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil { return internal(err) } + // 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE, + // 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。 + // 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。 + seen := make(map[int]bool, len(rules)) for _, r := range rules { if r.Rank < 1 { return badRequest("积分名次必须 >= 1", r) } + if seen[r.Rank] { + return badRequest(fmt.Sprintf("积分名次 %d 重复", r.Rank), r) + } + seen[r.Rank] = true if _, err := tx.ExecContext(ctx, `INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`, profileID, r.Rank, r.Points); err != nil { diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index b36a47c..a796d47 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -92,7 +92,11 @@ func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) { } func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) { - if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + // 与单位 / 大项 / 模板同口径:必填校验前先裁掉首尾空白,落库的也是裁过的值。 + // 编码是基础数据包按 event_code 匹配的键,带空格的编码与肉眼相同的正常编码并存时, + // 导入只会报「小项不存在」。 + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.SportID == 0 || in.Code == "" || in.Name == "" { return eventDTO{}, badRequest("sportId、code、name 必填", nil) } avgMode, err := normalizeAvgMode(in.AvgMode) @@ -159,6 +163,12 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) } func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) { + // 与新建同口径:更新路径此前完全不校验,一次 PUT 就能把小项编码清成空串 + // (规则包/项目包导出与导入匹配都会因此出错),且错误要到很久以后才暴露。 + in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) + if in.SportID == 0 || in.Code == "" || in.Name == "" { + return eventDTO{}, badRequest("sportId、code、name 必填", nil) + } avgMode, err := normalizeAvgMode(in.AvgMode) if err != nil { return eventDTO{}, err @@ -298,6 +308,9 @@ func multiplierOrOne(v float64) float64 { } // normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。 +// +// 它没跟其它枚举校验一起进 enums.go,也不在比对前 TrimSpace:`" valid"` 这类值会被当成 +// 非法直接拒成 400,而不是裁掉空格收下(P2-C24b)。 func normalizeAvgMode(v string) (string, error) { switch v { case "": @@ -312,6 +325,11 @@ func normalizeAvgMode(v string) (string, error) { // ---------- categories ---------- func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) { + // 只读接口也先确认小项存在:否则它给 200 空数组,而同一件事在整表替换上是 + // 404 / 500 / 400 三种答案(见 replaceCategories 的注释)。 + if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil { + return nil, err + } rows, err := s.ev.QueryContext(ctx, `SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID) @@ -364,6 +382,13 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate } defer func() { _ = tx.Rollback() }() + // 先确认小项存在:这是同一个「小项不存在」的四分之三——空表走到末尾 bumpEvent 才是 404, + // 非空表会一路撞 event_categories.event_id 的外键,报成 500「服务端异常」。 + // 在同一个事务里读,免得检查完、DELETE 前对方把小项删掉。 + if _, err := eventByIDQ(ctx, tx, eventID); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil { return nil, internal(err) } @@ -577,6 +602,10 @@ func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) ( // ---------- score channels ---------- func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) { + // 与 listCategories 同口径:小项不存在就 404,不给「200 空数组」。 + if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil { + return nil, err + } rows, err := s.ev.QueryContext(ctx, `SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_medals, count_to_team_medals @@ -625,6 +654,12 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe } defer func() { _ = tx.Rollback() }() + // 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时 + // 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。 + if _, err := eventByIDQ(ctx, tx, eventID); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { return nil, internal(err) } @@ -778,6 +813,13 @@ func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int if len(ids) == 0 { return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil) } + // 与 POST /api/entries/batch 同一把尺子、同一句文案(直接复用 maxEntryBatch): + // 两个接口处理的都是「一批报名项」,上限不该各说各话。 + // 位置与 entries_batch.go:73 一致——拦在任何一次读库 / 写库之前,先于下面那条 + // IN (…) 的巨型 SQL,几万条 id 的请求不会被拼进 SQL 再被拒。 + if len(ids) > maxEntryBatch { + return entryHeatResultDTO{}, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil) + } if heat != nil && (*heat < 1 || *heat > maxEntryHeat) { return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil) } diff --git a/server/internal/httpapi/repo_events_internal_test.go b/server/internal/httpapi/repo_events_internal_test.go new file mode 100644 index 0000000..9c7b83a --- /dev/null +++ b/server/internal/httpapi/repo_events_internal_test.go @@ -0,0 +1,66 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "io" + "log/slog" + "net/http" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// openEventDBForRepoTest 建一个跑过迁移的赛事库,外键按生产口径打开(DSN 里的 foreign_keys(1))。 +func openEventDBForRepoTest(t *testing.T) *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 db +} + +func assertNotFound(t *testing.T, err error, what string) { + t.Helper() + var api *apiError + if !errors.As(err, &api) { + t.Fatalf("%s 应返回业务错误,得到 %v", what, err) + } + if api.status != http.StatusNotFound || api.message != "小项不存在" { + t.Fatalf("%s 应 404「小项不存在」,得到 %d %s", what, api.status, api.message) + } +} + +// 整表替换这一层自己也要拦住不存在的小项。 +// +// HTTP 层现在会先经 listCategories / listChannels 拿到 404(handler 读 before), +// 所以这一条在端到端用例里看不到差别;但 repo 函数是独立入口,不能靠「调用方恰好先读了一次」 +// 兜底——漏了它,非空表会撞外键报 500、空表则要走到末尾 bumpEvent 才是 404。 +func TestReplaceCategoriesAndChannelsRejectMissingEvent(t *testing.T) { + s := &server{logger: slog.New(slog.NewTextHandler(io.Discard, nil)), ev: openEventDBForRepoTest(t)} + ctx := context.Background() + const missing = 99999 + + _, err := s.replaceCategories(ctx, missing, []categoryDTO{{Name: "男子", Multiplier: 1}}) + assertNotFound(t, err, "replaceCategories(非空表)") + + _, err = s.replaceCategories(ctx, missing, nil) + assertNotFound(t, err, "replaceCategories(空表)") + + _, err = s.replaceChannels(ctx, missing, []channelDTO{{Scope: "athlete", Basis: "result", ProfileID: 1}}) + assertNotFound(t, err, "replaceChannels(非空表)") + + _, err = s.replaceChannels(ctx, missing, nil) + assertNotFound(t, err, "replaceChannels(空表)") +} diff --git a/server/internal/httpapi/repo_events_test.go b/server/internal/httpapi/repo_events_test.go new file mode 100644 index 0000000..c6f3efd --- /dev/null +++ b/server/internal/httpapi/repo_events_test.go @@ -0,0 +1,86 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 「小项不存在」在组别 / 通道这一族接口上必须只有一个答案:404「小项不存在」。 +// +// 改前是四个:组别整表替换(非空表)撞外键报 500;组别整表替换(空表)走到末尾 +// bumpEvent 才是 404;通道整表替换报 400「profileId 不存在」(profileId 本身没问题, +// 缺的是小项,照着提示去查积分模板只会白费功夫);两个列表接口给 200 空数组。 +func TestNonexistentEventIsNotFoundOnCategoryAndChannelEndpoints(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + + const missing = 99999 + cats := fmt.Sprintf("/api/events/%d/categories", missing) + chans := fmt.Sprintf("/api/events/%d/channels", missing) + + for _, tc := range []struct { + label string + method string + path string + body any + }{ + {"组别列表", http.MethodGet, cats, nil}, + {"通道列表", http.MethodGet, chans, nil}, + {"组别整表替换(非空表)", http.MethodPut, cats, + map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}}, + {"组别整表替换(空表)", http.MethodPut, cats, + map[string]any{"categories": []map[string]any{}}}, + {"通道整表替换(非空表)", http.MethodPut, chans, + map[string]any{"channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 1}}}}, + {"通道整表替换(空表)", http.MethodPut, chans, + map[string]any{"channels": []map[string]any{}}}, + } { + status, resp := env.do(client, tc.method, tc.path, tc.body, true) + if status != http.StatusNotFound || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("%s 对不存在的小项应 404,得到 %d %+v", tc.label, status, resp.Error) + } + if resp.Error.Message != "小项不存在" { + t.Fatalf("%s 的 404 文案应是「小项不存在」,得到 %q", tc.label, resp.Error.Message) + } + } + + // 对照:真小项上照常可用,免得这条校验把合法请求一起挡掉。 + status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/categories", ids.event), nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("真小项的组别列表应 200,得到 %d %+v", status, resp.Error) + } + + status, resp = env.do(client, http.MethodPost, "/api/events", map[string]any{ + "sportId": ids.sport, "code": "P2A-CTRL", "name": "对照小项", + "eventType": "individual", "resultMode": "auto", "resultType": "time", + "compare": "asc", "scored": true, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("建对照小项应 200,得到 %d %+v", status, resp.Error) + } + ctrl := int64(decodeData[map[string]any](t, resp)["id"].(float64)) + + status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", ctrl), map[string]any{ + "categories": []map[string]any{{"name": "男子", "multiplier": 1}}, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("真小项的组别整表替换应 200,得到 %d %+v", status, resp.Error) + } + status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ctrl), map[string]any{ + "channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 1}}, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("真小项的通道整表替换应 200,得到 %d %+v", status, resp.Error) + } + + // 通道整表替换的「profileId 不存在」仍要指对字段:小项存在、模板不存在才是 400 指向 profileId。 + status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ctrl), map[string]any{ + "channels": []map[string]any{{"scope": "athlete", "basis": "result", "profileId": 99999}}, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "profileId") { + t.Fatalf("模板不存在应 400 并点明 profileId,得到 %d %+v", status, resp.Error) + } +} diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index b446421..22014de 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -53,8 +53,8 @@ type athleteImportMatch struct { Skip bool } -// sameNameNewPerson 是「身份字段不同 → 按同名的另一个人新建」的清单项: -// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。 +// sameNameNewPerson 是「新建的档案与库中已有的同名人员疑似重复」的清单项: +// 导入响应里单独列出来给现场核对,避免新建了档案却毫无提示。 // 证件号与号码布都要带上:现场要核对的是「表里这两个号有没有写错」。 type sameNameNewPerson struct { Name string `json:"name"` @@ -63,8 +63,22 @@ type sameNameNewPerson struct { ExistingIDCard string `json:"existingIdCard"` NewBib string `json:"newBib"` ExistingBib string `json:"existingBib"` + // Reason 说明为什么判成「另一个人」:identity = 证件号 / 号码布两边都有值且对不上; + // team = 这个单位下一个人都没有,但别的单位下有同名档案(多半是换了单位)。 + // 现场核对的重点完全不同,所以两者要分得开。 + Reason string `json:"reason"` + // ExistingTeamCode 是库中那条同名档案的单位编码(换单位场景要核对的就是它)。 + ExistingTeamCode string `json:"existingTeamCode"` } +// sameNameReason* 是 sameNameNewPerson.Reason 的取值。 +const ( + // sameNameReasonIdentity:表里的证件号(或号码布)与库中那条同名档案对不上 —— 多半是号写错了。 + sameNameReasonIdentity = "identity" + // sameNameReasonTeam:本次这个单位下查不到同名的,但别的单位下有 —— 换了单位(也可能真是两个人同名)。 + sameNameReasonTeam = "team" +) + // existingAthlete 是库中已有人员的可比字段。 type existingAthlete struct { ID int64 @@ -110,7 +124,7 @@ func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, } // matchByNameTeam 处理「证件号与号码布都没命中」的情况,按姓名 + 单位找候选: -// 0 条 → 新建;1 条且身份字段有一边没填或都对得上 → 命中并回填; +// 0 条 → 新建(并核对别的单位下有没有同名档案);1 条且身份字段有一边没填或都对得上 → 命中并回填; // 1 条但两边都有值且都对不上 → 同名的另一个人,新建并报告;多条 → 仍有歧义,进冲突面板等用户决策。 func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID) @@ -119,7 +133,19 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis } switch len(candidates) { case 0: - return s.createImportedAthlete(ctx, in) + created, err := s.createImportedAthlete(ctx, in) + if err != nil { + return athleteImportMatch{}, err + } + // 换了单位的人正是走到这一支:这个单位下查不到同名的,别的单位下却有一个。 + // 不能替用户认人(同名不同单位往往真是两个人),但更不能悄悄多建一份档案 —— + // 两份档案各自带报名,成绩与积分从此分家(清单 A13)。 + reported, err := s.sameNameElsewhere(ctx, in) + if err != nil { + return athleteImportMatch{}, err + } + created.SameNameNewPerson = reported + return created, nil case 1: ex := candidates[0] // 两边都给了证件号 / 号码布、且对不上时才算「同名的另一个人」: @@ -130,14 +156,11 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis if err != nil { return athleteImportMatch{}, err } - created.SameNameNewPerson = &sameNameNewPerson{ - Name: in.Name, - TeamCode: in.TeamCode, - NewIDCard: in.IDCard, - ExistingIDCard: strValue(ex.IDCard), - NewBib: in.Bib, - ExistingBib: strValue(ex.Bib), + reported, err := s.sameNameReport(ctx, in, ex, sameNameReasonIdentity) + if err != nil { + return athleteImportMatch{}, err } + created.SameNameNewPerson = reported return created, nil } return s.matchExistingAthlete(ctx, in, ex, decisions) @@ -146,6 +169,41 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis } } +// sameNameReport 组装一条「新建的档案与库中这条同名人员疑似重复」的报告。 +// existing 是库中那条同名档案(同单位的候选,或跨单位找到的那条),reason 说明为什么怀疑重复。 +func (s *server) sameNameReport(ctx context.Context, in athleteImportRow, existing existingAthlete, reason string) (*sameNameNewPerson, error) { + code, err := s.teamCodeByID(ctx, existing.TeamID) + if err != nil { + return nil, err + } + return &sameNameNewPerson{ + Name: in.Name, + TeamCode: in.TeamCode, + NewIDCard: in.IDCard, + ExistingIDCard: strValue(existing.IDCard), + NewBib: in.Bib, + ExistingBib: strValue(existing.Bib), + Reason: reason, + ExistingTeamCode: code, + }, nil +} + +// sameNameElsewhere 找库中同名、但不在本次这个单位下的人员,用于报告「换了单位」造成的新建档案。 +// 只报第一条(与多人候选只以第一条为代表同一口径):现场要核对的是「这个名字是不是本来就该是 +// 同一个人」,列一串同名人反而看不出重点。 +func (s *server) sameNameElsewhere(ctx context.Context, in athleteImportRow) (*sameNameNewPerson, error) { + ex, err := scanExistingAthlete(s.ev.QueryRowContext(ctx, + `SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id <> ? ORDER BY id LIMIT 1`, + in.Name, in.TeamID)) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, nil + case err != nil: + return nil, internal(err) + } + return s.sameNameReport(ctx, in, ex, sameNameReasonTeam) +} + // matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。 // 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选, // 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。 @@ -329,8 +387,12 @@ func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string { if ex.Name != in.Name { changed = append(changed, "name") } - // 性别只认 男/女:表里没填或填了其它值时不做比较,避免误报冲突。 - if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) { + // 性别与证件号/号码布同一口径(清单 A09):表里没填(含填了 男/女 以外的值)= 不参与比较; + // 库中为空 = 待回填,不算差异。之前是「库里为空就算差异」,一份没填性别列的旧名单会让 + // 所有库里性别为空的人全掉进冲突面板(这些人本来还在静默回填证件号/号码布/年龄), + // 而性别又从不回填 —— 现场只能逐条点「使用新数据」,与 devdocs/05 §7 的匹配表相反。 + // 只有两边都有值且不同(男 ↔ 女)才是真冲突。 + if in.Gender != "" && ex.Gender != nil && *ex.Gender != "" && *ex.Gender != in.Gender { changed = append(changed, "gender") } // 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。 @@ -354,7 +416,7 @@ func identityDiffers(dbVal *string, inVal string) bool { return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal } -// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。 +// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄/性别,已有值不动(差异已由 changedAthleteFields 判过)。 func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error { if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") { if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil { @@ -379,6 +441,13 @@ func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthl return internal(err) } } + // 性别与证件号/号码布/年龄一并回填:历史名单里性别为空是常态,口径就是「库中为空 = 待回填」, + // 不回填就等于把这批人永远留在空性别上,下一份表再导入还会被当成差异(清单 A09)。 + if in.Gender != "" && (ex.Gender == nil || *ex.Gender == "") { + if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET gender = ? WHERE id = ?`, in.Gender, ex.ID); err != nil { + return internal(err) + } + } return nil } @@ -456,6 +525,18 @@ type entryImportOutcome struct { // StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。 // 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。 StatusChanged bool + // MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。 + // 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见 + // 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。 + MovedEntries []movedSameScopeEntry +} + +// movedSameScopeEntry 报告一条「换单位后旧单位那条报名已被移出」。 +// 姓名与新/旧单位都要带上:现场要核对的正是「这个人是不是真的换了单位」。 +type movedSameScopeEntry struct { + Name string + OldTeamCode string + NewTeamCode string } // entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径: @@ -465,10 +546,111 @@ func entryStatusRankable(status string) bool { return status == "" || status == string(domain.EntryStatusRegistered) } +// staleScopeEntry 是「同一个人 + 同一个小项 + 同一个组别」下、单位与本次导入不同的旧报名。 +type staleScopeEntry struct { + ID int64 + Name string + TeamCode string +} + +// releaseStaleSameScopeEntries 在给这个人**新建**报名之前,处置同一个小项同一个组别里 +// 单位与本次不同的旧报名。 +// +// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:keep-new 把人员 +// 改到新单位后,旧单位那条报名会原样留着 —— 同一个人在同一个组别出现两条,成绩录入页看得见 +// 两条,现场各录一次成绩就是同一人同一组别两条成绩、重复计分(清单 A10); +// docs/报名名单导入规范.md 也承诺同一个项目的报名「不会产生重复报名」。 +// +// 处置只有两种,都不许凭空销毁数据: +// - 旧报名没有任何成绩痕迹(results / score_ledger / record_breaks 全空)→ 按批量移出的口径 +// 物理移出。它只是一条过期的名单行,删掉不丢任何数据;新单位这条紧接着新建, +// 调用方拿到 Created 就会重算这一组(与 recomputeImportedScopes 同一口径, +// 团体平均口径的分母也跟着这一增一减重算)。 +// - 旧报名已经留下成绩痕迹 → 一行不动并拦下本行:删掉等于抹掉已经产生的名次与积分, +// 那属于「成绩 / 榜单」的处置,必须由用户到成绩录入页用批量移出显式做 +// (removeEntriesTx 会连带清流水并重算),导入不许替他做这个决定。 +// +// 返回被移出的旧报名(可能为空):调用方要拿它给操作员一条 notices 提示(清单 A10)。 +func (s *server) releaseStaleSameScopeEntries(ctx context.Context, in entryDTO) ([]staleScopeEntry, error) { + // 团体行(athlete_id 为空)不受影响:同一个组别里两个单位各报一支队伍本来就是正常的。 + if in.AthleteID == nil { + return nil, nil + } + rows, err := s.ev.QueryContext(ctx, + `SELECT e.id, a.name, t.code FROM entries e + JOIN athletes a ON a.id = e.athlete_id + JOIN teams t ON t.id = e.team_id + WHERE e.event_id = ? AND e.category_id = ? AND e.athlete_id = ? AND e.team_id <> ? + ORDER BY e.id`, in.EventID, in.CategoryID, *in.AthleteID, in.TeamID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + stale := make([]staleScopeEntry, 0, 1) + for rows.Next() { + var item staleScopeEntry + if err := rows.Scan(&item.ID, &item.Name, &item.TeamCode); err != nil { + return nil, internal(err) + } + stale = append(stale, item) + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + for _, item := range stale { + scored, err := s.entryHasResultTraces(ctx, item.ID) + if err != nil { + return nil, err + } + if scored { + return nil, staleScoredEntryError(item) + } + } + for _, item := range stale { + // 上面已确认这条旧报名没有任何成绩痕迹,所以只删报名行本身:批量移出(removeEntriesTx) + // 还要连带清流水 / 成绩 / 破纪录,是因为那边允许移出已有成绩的报名,这里没有可清的。 + if _, err := s.ev.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, item.ID); err != nil { + // 并发下刚有人给这条旧报名录了成绩:results 对 entries 没有 ON DELETE,外键会挡住删除。 + // 那就按「已有成绩」处理并拦下本行,绝不强行删掉别人的成绩痕迹。 + if isForeignKeyViolation(err) { + return nil, staleScoredEntryError(item) + } + return nil, internal(err) + } + } + return stale, nil +} + +// entryHasResultTraces 判断一条报名是否已经留下成绩痕迹:录过成绩(草稿也算)、 +// 有过流水、或登记过破纪录,任意一样成立就不再是「可以随手移出的过期名单行」。 +func (s *server) entryHasResultTraces(ctx context.Context, entryID int64) (bool, error) { + var traces int + if err := s.ev.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM results WHERE entry_id = ?) + OR EXISTS(SELECT 1 FROM score_ledger WHERE entry_id = ?) + OR EXISTS(SELECT 1 FROM record_breaks WHERE entry_id = ?)`, + entryID, entryID, entryID).Scan(&traces); err != nil { + return false, internal(err) + } + return traces != 0, nil +} + +// staleScoredEntryError 是「旧报名已有成绩」时的行级提示:不能只说不能做,必须给出下一步。 +// 返回纯 error 而不是 apiError:调用方(handlers_config.go 的导入主循环)对 upsert 的失败 +// 用 err.Error() 原样回显,包成 apiError 会多出「CONFLICT:」前缀,现场看不懂。 +func staleScoredEntryError(item staleScopeEntry) error { + return fmt.Errorf("%s 在本组别还有一条单位 %s 的报名(已录过成绩),换单位后同一个人在同一组别不能留两条;请先到成绩录入页把单位 %s 那条移出,再重新上传这一行", + item.Name, item.TeamCode, item.TeamCode) +} + // upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。 // // 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把 // 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。 +// +// 唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:新建之前必须先处置 +// 同一个小项同一个组别里另一单位的那条旧报名(见 releaseStaleSameScopeEntries)。 func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImportOutcome, error) { var ( existing int64 @@ -488,6 +670,10 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp status := orDefault(in.Status, "registered") switch { case errors.Is(err, sql.ErrNoRows): + moved, err := s.releaseStaleSameScopeEntries(ctx, in) + if err != nil { + return entryImportOutcome{}, err + } if _, err := s.ev.ExecContext(ctx, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, @@ -495,7 +681,21 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status); err != nil { return entryImportOutcome{}, err } - return entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}, nil + outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID} + if len(moved) > 0 { + // 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分, + // 平时的导入不为它付代价。 + newCode, err := s.teamCodeByID(ctx, in.TeamID) + if err != nil { + return entryImportOutcome{}, err + } + for _, item := range moved { + outcome.MovedEntries = append(outcome.MovedEntries, movedSameScopeEntry{ + Name: item.Name, OldTeamCode: item.TeamCode, NewTeamCode: newCode, + }) + } + } + return outcome, nil case err != nil: return entryImportOutcome{}, err default: diff --git a/server/internal/httpapi/repo_import_test.go b/server/internal/httpapi/repo_import_test.go new file mode 100644 index 0000000..f822597 --- /dev/null +++ b/server/internal/httpapi/repo_import_test.go @@ -0,0 +1,405 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "testing" +) + +// 本文件覆盖报名导入里三条联动口径(清单 2026-09-11 的 A09 / A10 / A13): +// - A09 性别:库中为空 = 待回填(不算冲突),表里没填 = 不参与比较,库中 ↔ 表里都是男/女且不同才是真冲突; +// - A10 换单位:同一个人在同一个小项同一个组别只留一条报名,旧报名已有成绩时不许悄悄删; +// - A13 换单位(没号码布):新建的第二份档案必须在 sameNameNewPersons 里报告出来。 +// +// 断言全部落到库里的字段值与榜单副作用上,不停在 HTTP 200。 + +// importGenderRow 拼一行「M100 / 男子 / 单位 A / 张伟」,只给号码布与性别(性别可为空串)。 +// 表头顺序见 importHeader:event_code, category, team_code, athlete_name, id_card, gender, age, bib, seed, status, member_names。 +func importGenderRow(gender string, bib string) string { + return "M100,男子,A,张伟,," + gender + ",," + bib + ",,registered,\n" +} + +// A09:库里性别为空、表里填了 男 → 命中同一人并**回填**性别,不产生冲突。 +// 历史名单里性别为空是常态,按旧口径这些人会整批掉进冲突面板(而性别又从不回填), +// 现场只能逐条点「使用新数据」。 +func TestImportBackfillsEmptyGenderInsteadOfConflict(t *testing.T) { + env, ids, client := recorderWithSeed(t) + // 种子人员张伟在库里性别是 男,先清空成历史数据的样子(UI 允许性别为空)。 + env.exec(t, `UPDATE athletes SET gender = NULL WHERE id = ?`, ids.athleteA) + + status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importGenderRow("男", "A900"), "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("库里性别为空不该算差异: %+v", conflicts) + } + if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) { + t.Fatalf("应命中已有人员并更新报名: %+v", result) + } + + var gender sql.NullString + if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil { + t.Fatalf("查询人员性别: %v", err) + } + if !gender.Valid || gender.String != "男" { + t.Fatalf("表里给了性别就该回填,得到 %+v", gender) + } + // 回填与报名同一条路径:库里那条报名照样在,人没有被拦在冲突里。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = ?`, ids.event, ids.athleteA); n != 1 { + t.Fatalf("应有 1 条报名,得到 %d", n) + } + // 号码布一并回填,证明走的是「命中并补空字段」那条路,而不是别的新建路径。 + var bib sql.NullString + if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil { + t.Fatalf("查询人员号码布: %v", err) + } + if !bib.Valid || bib.String != "A900" { + t.Fatalf("号码布应一并回填,得到 %+v", bib) + } +} + +// A09:表里性别列留空 → 不参与比较:既不算冲突,也不去清库里的值(库中为空就保持为空)。 +func TestImportBlankGenderColumnIsNotCompared(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET gender = NULL WHERE id = ?`, ids.athleteA) + + status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importGenderRow("", "A900"), "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("表里没填性别不该算差异: %+v", conflicts) + } + if result["updated"] != float64(1) || result["skipped"] != float64(0) { + t.Fatalf("应照常更新报名: %+v", result) + } + + var gender sql.NullString + if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil { + t.Fatalf("查询人员性别: %v", err) + } + if gender.Valid { + t.Fatalf("表里没填性别就不该写库,得到 %+v", gender) + } +} + +// A09:真正的性别冲突(库里 男 ↔ 表里 女)仍要进冲突面板,不能被新口径放过。 +func TestImportRealGenderConflictStillGoesToPanel(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + row := "M100,男子,A,张伟,,女,,A900,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+row, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + conflicts, _ := result["conflicts"].([]any) + if len(conflicts) != 1 { + t.Fatalf("男 ↔ 女必须进冲突面板,得到 %+v", conflicts) + } + conflict := conflicts[0].(map[string]any) + changed, _ := conflict["changedFields"].([]any) + if len(changed) != 1 || changed[0] != "gender" { + t.Fatalf("差异应只有 gender,得到 %+v", changed) + } + if result["updated"] != float64(0) || result["created"] != float64(0) { + t.Fatalf("冲突行不该落库: %+v", result) + } + var gender string + if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil { + t.Fatalf("查询人员性别: %v", err) + } + if gender != "男" { + t.Fatalf("未决策前不该改库,得到 %s", gender) + } + + // 决策「使用新数据」:库里的性别才被覆盖成表里的值。 + status, env2 = env.postImport(t, client, "/api/entries/import/resolve", importHeader+"\n"+row, + `[{"bib":"A900","action":"keep-new"}]`) + if status != http.StatusOK || !env2.OK { + t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error) + } + result = decodeData[map[string]any](t, env2) + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("已决策的冲突不应再返回: %+v", conflicts) + } + if result["updated"] != float64(1) { + t.Fatalf("keep-new 应更新已有报名: %+v", result) + } + if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil { + t.Fatalf("查询人员性别: %v", err) + } + if gender != "女" { + t.Fatalf("keep-new 应用表里的性别覆盖,得到 %s", gender) + } +} + +// A10:换单位 + keep-new 后,同一个人在同一个小项同一个组别只留一条报名。 +// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开唯一索引: +// 旧单位那条报名会原样留着,现场各录一次成绩就是同一人同一组别两条成绩、重复计分。 +func TestImportTeamChangeKeepsOneEntryPerScope(t *testing.T) { + env, ids, client := recorderWithSeed(t) + seedZhangWeiIDCard(t, env, ids) + // 前置:张伟在「男子」组有一条 A 单位的报名(种子 entryA),没有成绩。 + pushesBefore := standingsPushes(t, env) + + body := importHeader + "\nM100,男子,B,张伟," + importZhangWeiIDCard + ",男,,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, + `[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`) + if status != http.StatusOK || !env2.OK { + t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(1) || result["updated"] != float64(0) || result["skipped"] != float64(0) { + t.Fatalf("换单位应在新单位下建这条报名: %+v", result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("已决策的冲突不应再返回: %+v", conflicts) + } + // 数据对了还不够:旧单位的报名被移出这件事必须在导入结果里讲出来, + // 否则现场只看到「旧单位那条不见了」,分不清是移出还是丢了(清单 A10)。 + notices, _ := result["notices"].([]any) + if len(notices) != 1 { + t.Fatalf("换单位应给 1 条提示,得到 %+v", notices) + } + want := "第 2 行:张伟 换了单位,原单位 A 在本组别的报名已移出,已改在单位 B 加入" + if notices[0] != want { + t.Fatalf("提示应写明姓名与新/旧单位,want %q got %q", want, notices[0]) + } + + // 旧单位那条过期的报名行被移出(它没有任何成绩痕迹,删掉不丢数据)。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 0 { + t.Fatalf("旧单位的过期报名应被移出,得到 %d 条", n) + } + // 同一人在同一组别只剩新单位那一条。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND athlete_id = ?`, + ids.event, ids.category, ids.athleteA); n != 1 { + t.Fatalf("同一人同一组别只应留一条报名,得到 %d 条", n) + } + var teamID int64 + if err := env.ev.QueryRow(`SELECT team_id FROM entries WHERE event_id = ? AND athlete_id = ?`, + ids.event, ids.athleteA).Scan(&teamID); err != nil { + t.Fatalf("查询留下的报名: %v", err) + } + if teamID != ids.teamB { + t.Fatalf("留下的那条应属于新单位,得到 %d", teamID) + } + // 别人的报名不受影响。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 1 { + t.Fatalf("别的报名不该被牵连,得到 %d 条", n) + } + // 名单一增一减动了这一组的参赛成员 → 必须重算并推榜(与 recomputeImportedScopes 同一口径)。 + if got := standingsPushes(t, env) - pushesBefore; got != 1 { + t.Fatalf("换单位应重算这一组并推 1 次榜单,得到 %d", got) + } + + // 已经在 B 单位了,再传同一份表:只是就地更新,没有谁被移出,不该再冒这条提示。 + status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, + `[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`) + if status != http.StatusOK || !env2.OK { + t.Fatalf("重传应 200,得到 %d %+v", status, env2.Error) + } + again := decodeData[map[string]any](t, env2) + if again["updated"] != float64(1) || again["created"] != float64(0) { + t.Fatalf("重传应就地更新: %+v", again) + } + if notices, _ := again["notices"].([]any); len(notices) != 0 { + t.Fatalf("没有报名被移出就不该有提示,得到 %+v", notices) + } +} + +// A10:旧单位那条报名已经录过成绩 → 一行不动并拦下本行。 +// 删掉它等于抹掉已经产生的名次与积分,那是「成绩 / 榜单」的处置,必须由用户用批量移出显式做。 +func TestImportTeamChangeWithScoredOldEntryIsBlocked(t *testing.T) { + env, ids, client := recorderWithSeed(t) + seedZhangWeiIDCard(t, env, ids) + publishResults(t, env, client, ids.event, "import-team-change", + 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"}, + ) + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { + t.Fatalf("前置条件:张伟应是第 1 名 10 分,得到 %v", got) + } + pushesBefore := standingsPushes(t, env) + + body := importHeader + "\nM100,男子,B,张伟," + importZhangWeiIDCard + ",男,,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, + `[{"idCard":"`+importZhangWeiIDCard+`","action":"keep-new"}]`) + if status != http.StatusOK || !env2.OK { + t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(0) || result["updated"] != float64(0) || result["skipped"] != float64(1) { + t.Fatalf("这一行应被拦下(不建第二条报名),得到 %+v", result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("应给出一条行级提示,得到 %+v", errs) + } + line, _ := errs[0].(string) + if !strings.Contains(line, "移出") || !strings.Contains(line, "第 2 行") { + t.Fatalf("提示要带行号并说清下一步(先把旧报名移出),得到 %q", line) + } + // 旧报名与它的成绩、流水、名次、积分一个都没动。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 { + t.Fatalf("已有成绩的旧报名不该被删,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, ids.entryA); n != 1 { + t.Fatalf("旧报名的已发布成绩应原样保留,得到 %d 条", n) + } + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { + t.Fatalf("旧报名的流水应原样保留,得到 %v", got) + } + if got := rankOfEntry(t, env, ids.entryA); got != 1 { + t.Fatalf("旧报名的名次应原样保留,得到 %d", got) + } + // 新单位下没有多出第二条报名。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND team_id = ? AND athlete_id = ?`, + ids.event, ids.category, ids.teamB, ids.athleteA); n != 0 { + t.Fatalf("被拦下的行不该落库,得到 %d 条", n) + } + // 本行没有落库,就没有「谁还算数」的变化:不重算、不推榜。 + if got := standingsPushes(t, env) - pushesBefore; got != 0 { + t.Fatalf("本行未落库不该推榜,得到 %d", got) + } + + // 现场怎么补救:先在成绩录入页把旧报名移出(会连带清成绩与流水并重算),再重新上传这一行。 + status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "remove", "ids": []int64{ids.entryA}, "reason": "换单位,移出旧报名", + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("批量移出应 200,得到 %d %+v", status, env2.Error) + } + status, env2 = env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("移出后重新导入应 200,得到 %d %+v", status, env2.Error) + } + result = decodeData[map[string]any](t, env2) + if result["created"] != float64(1) || result["skipped"] != float64(0) { + t.Fatalf("移出旧报名后应能建上新单位这条: %+v", result) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND category_id = ? AND athlete_id = ?`, + ids.event, ids.category, ids.athleteA); n != 1 { + t.Fatalf("补救后同一人同一组别应只有一条报名,得到 %d 条", n) + } +} + +// A13:没号码布的人换了单位 → 这个单位下查不到同名的,会新建第二份档案, +// 必须把「别的单位下有个同名档案」列进 sameNameNewPersons,不能悄悄多一个人。 +func TestImportTeamChangeReportsSameNameElsewhere(t *testing.T) { + env, ids, client := recorderWithSeed(t) + // 种子张伟在 A 单位,既没有号码布也没有证件号;表里换到 B 单位并带上他的证件号与号码布。 + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,B900,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(1) || result["skipped"] != float64(0) { + t.Fatalf("新单位下应新建报名: %+v", result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("这不是冲突,是同名疑似重复: %+v", conflicts) + } + reported, _ := result["sameNameNewPersons"].([]any) + if len(reported) != 1 { + t.Fatalf("换单位新建的档案必须报告出来,得到 %+v", reported) + } + item := reported[0].(map[string]any) + want := map[string]any{ + "name": "张伟", "teamCode": "B", "reason": "team", "existingTeamCode": "A", + "newIdCard": "110101199001011234", "existingIdCard": "", + "newBib": "B900", "existingBib": "", + } + for key, value := range want { + if item[key] != value { + t.Fatalf("报告字段 %s 应为 %q,得到 %+v", key, value, item) + } + } + // 两份档案都还在(新建是既有口径,这一条修的是「不许无声」),老档案一点没被动。 + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE name = '张伟'`); n != 2 { + t.Fatalf("应有 2 份同名档案,得到 %d 份", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND team_id = ? AND id_card IS NULL AND bib IS NULL`, + ids.athleteA, ids.teamA); n != 1 { + t.Fatalf("A 单位那份老档案不该被改,得到 %d 份", n) + } +} + +// A13 的反例保护:同单位、同名、身份字段对不上 → 报告的原因是 identity,不能被跨单位这条口径顶掉。 +func TestImportSameTeamSameNameStillReportsIdentityReason(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + reported, _ := result["sameNameNewPersons"].([]any) + if len(reported) != 1 { + t.Fatalf("同名不同号码布应报告 1 条,得到 %+v", reported) + } + item := reported[0].(map[string]any) + if item["reason"] != "identity" || item["existingTeamCode"] != "A" { + t.Fatalf("同单位这条应报 identity 并带上库中单位,得到 %+v", item) + } +} + +// A09 的现场形态:一整份没有性别列的旧名单先导一遍,第二天补上性别再传一遍 —— +// 第二遍必须零冲突、全员回填,且一个人都不会变成两份报名。 +func TestImportRosterWithoutGenderThenBackfillsAtScale(t *testing.T) { + env, ids, client := recorderWithSeed(t) + const rows = 60 + + build := func(gender string) string { + body := importHeader + "\n" + for i := 0; i < rows; i++ { + body += fmt.Sprintf("M100,男子,A,选手%02d,11010119900101%04d,%s,,B%03d,,registered,\n", + i, i+1, gender, i+1) + } + return body + } + + status, env2 := env.postImport(t, client, "/api/entries/import", build(""), "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(rows) || result["skipped"] != float64(0) { + t.Fatalf("第一遍应新建 %d 条报名: %+v", rows, result) + } + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("第一遍不该有冲突: %+v", conflicts) + } + // 第一遍的性别列整列没填 → 新档案里就是空性别(历史数据的常态)。 + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ? AND gender IS NULL`, ids.teamA); n != rows { + t.Fatalf("应有 %d 人性别为空,得到 %d", rows, n) + } + + // 第二遍补上性别:库里性别为空 = 待回填,一个人都不该掉进冲突面板。 + status, env2 = env.postImport(t, client, "/api/entries/import", build("男"), "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("第二遍导入应 200,得到 %d %+v", status, env2.Error) + } + result = decodeData[map[string]any](t, env2) + if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { + t.Fatalf("库里性别为空不该算差异,第二遍不该有冲突: %+v", conflicts) + } + if result["updated"] != float64(rows) || result["created"] != float64(0) || result["skipped"] != float64(0) { + t.Fatalf("第二遍应就地更新 %d 条: %+v", rows, result) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ? AND gender = '男'`, ids.teamA); n != rows+1 { + t.Fatalf("应有 %d 人回填成 男,得到 %d", rows+1, n) + } + // 重传同一份表不会把名单变成两份:加了换单位去重后,同一个人在同一组别始终只留一条报名。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, ids.event); n != rows+2 { + t.Fatalf("报名总数应是 %d(%d 行 + 种子两人的报名),得到 %d", rows+2, rows, n) + } +} diff --git a/server/internal/httpapi/repo_test.go b/server/internal/httpapi/repo_test.go index 5ecb3d3..4368a1e 100644 --- a/server/internal/httpapi/repo_test.go +++ b/server/internal/httpapi/repo_test.go @@ -490,3 +490,265 @@ func TestProfileLastPlaceZeroIsBooleanTyped(t *testing.T) { } } } + +// assertCodeNameRequired 钉住「必填」的三态:传空串、传纯空格、整段漏传,都必须是 +// 400 而不是「200 + 把库里的编码清掉」。历史伤疤:漏传被当成清空,编码是导入匹配的键, +// 清掉之后导入只会报「单位不存在」,而现场当场看不出是刚才那次编辑干的。 +func assertCodeNameRequired(t *testing.T, env *testEnv, client *http.Client, what, path, table string, id int64, base map[string]any) { + t.Helper() + for _, bad := range []struct { + label string + code string + name string + }{ + {"空串", "", ""}, + {"纯空格", " ", " "}, + {"漏传", "", ""}, // code/name 两个键都不出现在请求体里 + } { + body := map[string]any{} + for k, v := range base { + body[k] = v + } + if bad.label != "漏传" { + body["code"], body["name"] = bad.code, bad.name + } + status, resp := env.do(client, http.MethodPut, path, body, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("%s code/name %s 应 400 VALIDATION_FAILED,得到 %d %+v", what, bad.label, status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "必填") { + t.Fatalf("%s code/name %s 的 400 文案应点明必填,得到 %q", what, bad.label, resp.Error.Message) + } + } + + var code string + if err := env.ev.QueryRow(`SELECT code FROM `+table+` WHERE id = ?`, id).Scan(&code); err != nil { + t.Fatalf("读%s编码: %v", what, err) + } + if strings.TrimSpace(code) == "" { + t.Fatalf("%s 被拒的请求不该改库中编码,得到 %q", what, code) + } +} + +// PUT 单位 / 大项 / 小项时,code 与 name 是必填:空串、纯空格、漏传一律 400,库里的编码不动。 +// 改前这三类请求都是 200,落库 code=""(或 " ")——小项与单位的编码正是导入按 +// team_code / event_code 匹配的键,清空之后导入永远报「不存在」。 +func TestUpdateRequiresCodeAndName(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + + assertCodeNameRequired(t, env, client, "单位", fmt.Sprintf("/api/teams/%d", ids.teamA), "teams", ids.teamA, nil) + assertCodeNameRequired(t, env, client, "大项", fmt.Sprintf("/api/sports/%d", ids.sport), "sports", ids.sport, nil) + assertCodeNameRequired(t, env, client, "小项", fmt.Sprintf("/api/events/%d", ids.event), "events", ids.event, + map[string]any{"sportId": ids.sport}) + + // 对照:合法的更新照常 200,且首尾空白被裁掉后再落库(编码「 A2 」与「A2」从此不可能并存)。 + status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/teams/%d", ids.teamA), map[string]any{ + "code": " A2 ", "name": " 第一分公司 ", "active": true, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("带空白的合法更新应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[map[string]any](t, resp); got["code"] != "A2" || got["name"] != "第一分公司" { + t.Fatalf("更新应落裁过空白的值,得到 %+v", got) + } + + // 新建侧同口径:POST 带空白的编码也按裁过的值落库。 + status, resp = env.do(client, http.MethodPost, "/api/teams", map[string]any{ + "code": " WS ", "name": " 带空格 ", "active": true, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("建单位应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[map[string]any](t, resp); got["code"] != "WS" || got["name"] != "带空格" { + t.Fatalf("新建应落裁过空白的值,得到 %+v", got) + } +} + +// A01:PUT /api/scoring-profiles/{id} 空 code/name 必须是 400。 +// 改前它是 200:recorder 一按保存就能把模板的 code 清空,而 rules/validate.go 仍要求 +// code 非空 —— admin 的 GET /api/rules/export 与 project.zip 从此永久 500,且没有任何提示。 +func TestUpdateProfileCodeRequiredKeepsRulesExportUsable(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + admin, _ := env.client() + env.login(admin, "admin", "admin-pass") + rec := recorderClient(t, env) + + rulesExport := func() int { + t.Helper() + resp, err := admin.Get(env.server.URL + "/api/rules/export") + if err != nil { + t.Fatalf("取规则包: %v", err) + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode + } + if got := rulesExport(); got != http.StatusOK { + t.Fatalf("改前规则包导出应 200,得到 %d", got) + } + + for _, body := range []map[string]any{ + {"code": "", "name": ""}, + {"code": " ", "name": " "}, + {"tieMode": "skip"}, // 漏传 code / name + } { + status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", body, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("%v 应 400 VALIDATION_FAILED,得到 %d %+v", body, status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "必填") { + t.Fatalf("400 文案应点明必填,得到 %q", resp.Error.Message) + } + } + + var code string + if err := env.ev.QueryRow(`SELECT code FROM scoring_profiles WHERE id = 1`).Scan(&code); err != nil { + t.Fatalf("读模板编码: %v", err) + } + if code != "individual_top6" { + t.Fatalf("被拒的更新不该改库中编码,得到 %q", code) + } + if got := rulesExport(); got != http.StatusOK { + t.Fatalf("被拒的更新之后规则包导出仍应 200,得到 %d", got) + } + + // 对照:合法的更新照常 200,落库的是裁掉空白后的值。 + status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", map[string]any{ + "code": " P1 ", "name": " 个人项目前六名 ", + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("合法更新应 200,得到 %d %+v", status, resp.Error) + } + if got := decodeData[map[string]any](t, resp); got["code"] != "P1" || got["name"] != "个人项目前六名" { + t.Fatalf("更新应落裁过空白的值,得到 %+v", got) + } +} + +// profileRulesView 取模板的名次表,用来断言「被拒的更新没有把名次删空」。 +type profileRulesView struct { + Rules []struct { + Rank int `json:"rank"` + } `json:"rules"` +} + +func profileRuleRanks(t *testing.T, env *testEnv, client *http.Client, id int64) []int { + t.Helper() + status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", id), nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读模板应 200,得到 %d %+v", status, resp.Error) + } + view := decodeData[profileRulesView](t, resp) + ranks := make([]int, 0, len(view.Rules)) + for _, r := range view.Rules { + ranks = append(ranks, r.Rank) + } + return ranks +} + +// rules[] 里两行同 rank 撞 scoring_profile_rules 主键 (profile_id, rank): +// 改前是 500 + 英文约束原文,现场只看到「服务端异常」,无从知道是哪一格填重了。 +func TestProfileRulesDuplicateRankRejected(t *testing.T) { + env := newTestEnv(t) + client := recorderClient(t, env) + + status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ + "code": "DUP-RANK", "name": "重复名次", + "rules": []map[string]any{{"rank": 1, "points": 10}, {"rank": 1, "points": 8}}, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("新建重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "1") { + t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'DUP-RANK'`); n != 0 { + t.Fatalf("被拒的模板不该落库,得到 %d 行", n) + } + + // 更新路径同口径;replaceProfileRules 是「先 DELETE 再 INSERT」,被拒时整个事务必须回滚, + // 否则模板会被存成「名次表空了」这种更坏的状态。 + before := profileRuleRanks(t, env, client, 1) + if len(before) == 0 { + t.Fatalf("种子模板应自带名次表") + } + status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{ + "code": "individual_top6", "name": "个人项目前六名", + "rules": []map[string]any{{"rank": 2, "points": 9}, {"rank": 2, "points": 8}}, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("更新重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "2") { + t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message) + } + if !strings.Contains(string(resp.Error.Detail), `"rank":2`) { + t.Fatalf("400 的 detail 应带上重复的那一行,得到 %s", resp.Error.Detail) + } + after := profileRuleRanks(t, env, client, 1) + if len(after) != len(before) { + t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after) + } + for i := range before { + if before[i] != after[i] { + t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after) + } + } +} + +// defaultProfileId 指向不存在的模板:改前撞 scoring_profiles 的外键报 500「服务端异常」, +// 同类的 events.sport_id / channels.profile_id 都是 400。两条写入路径都要一致。 +func TestSportDefaultProfileMustExist(t *testing.T) { + env := newTestEnv(t) + client := recorderClient(t, env) + + status, resp := env.do(client, http.MethodPost, "/api/sports", map[string]any{ + "code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("新建指向不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "defaultProfileId") { + t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM sports WHERE code = 'SWIM'`); n != 0 { + t.Fatalf("被拒的大项不该落库,得到 %d 行", n) + } + + status, resp = env.do(client, http.MethodPost, "/api/sports", map[string]any{ + "code": "SWIM", "name": "游泳", "defaultProfileId": 1, "active": true, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("建大项应 200,得到 %d %+v", status, resp.Error) + } + sportID := int64(decodeData[map[string]any](t, resp)["id"].(float64)) + + status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{ + "code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("更新到不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + var profileID sql.NullInt64 + if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil { + t.Fatalf("读默认模板: %v", err) + } + if !profileID.Valid || profileID.Int64 != 1 { + t.Fatalf("被拒的更新不该改默认模板,得到 %+v", profileID) + } + + // 对照:指向真实模板照常可写。 + status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{ + "code": "SWIM", "name": "游泳", "defaultProfileId": 2, "active": true, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("指向真实模板应 200,得到 %d %+v", status, resp.Error) + } + if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil { + t.Fatalf("读默认模板: %v", err) + } + if !profileID.Valid || profileID.Int64 != 2 { + t.Fatalf("合法更新应落库 2,得到 %+v", profileID) + } +} diff --git a/server/internal/httpapi/request_body_limit_internal_test.go b/server/internal/httpapi/request_body_limit_internal_test.go new file mode 100644 index 0000000..a8d9965 --- /dev/null +++ b/server/internal/httpapi/request_body_limit_internal_test.go @@ -0,0 +1,74 @@ +package httpapi + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// 8 MiB 上限必须落在最通用的解码路径上:三十多个写接口走的都是 decodeJSON, +// 此前只有 decodeJSONPresence(建人员 / 单位那几个)卡过大小,普通 JSON 写接口没有上限。 +func TestDecodeJSONRejectsOversizedBody(t *testing.T) { + body := `{"name":"` + strings.Repeat("a", maxJSONBodyBytes) + `"}` + req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(body)) + + var dst map[string]any + err := decodeJSON(req, &dst) + + var api *apiError + if !errors.As(err, &api) { + t.Fatalf("超限应返回业务错误,得到 %v", err) + } + if api.status != http.StatusRequestEntityTooLarge { + t.Fatalf("超限应 413(而不是笼统 400「不是合法 JSON」),得到 %d %s", api.status, api.message) + } + if api.message != "请求体超过上限" { + t.Fatalf("超限文案应说清是请求体太大,得到 %q", api.message) + } +} + +// 上限不能顺手把正当请求拦掉:普通写请求照常解析。 +func TestDecodeJSONAcceptsNormalBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/settings", + strings.NewReader(`{"event":{"name":"秋季运动会"}}`)) + + var dst struct { + Event struct { + Name string `json:"name"` + } `json:"event"` + } + if err := decodeJSON(req, &dst); err != nil { + t.Fatalf("正常请求体应解析成功: %v", err) + } + if dst.Event.Name != "秋季运动会" { + t.Fatalf("解析结果不对: %+v", dst) + } +} + +// 非法 JSON 仍然是 400「请求体不是合法 JSON」,不要被超限分支吞掉。 +func TestDecodeJSONStillReportsBadJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader(`{"event":`)) + + var dst map[string]any + err := decodeJSON(req, &dst) + + var api *apiError + if !errors.As(err, &api) { + t.Fatalf("非法 JSON 应返回业务错误,得到 %v", err) + } + if api.status != http.StatusBadRequest || api.message != "请求体不是合法 JSON" { + t.Fatalf("非法 JSON 应 400「请求体不是合法 JSON」,得到 %d %s", api.status, api.message) + } +} + +// 空体仍要报错(「没传参数」不是合法写操作)——加了上限不能改变这条既有口径。 +func TestDecodeJSONStillRejectsEmptyBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/settings", strings.NewReader("")) + + var dst map[string]any + if err := decodeJSON(req, &dst); err == nil { + t.Fatal("空请求体应报错") + } +} diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go index 463d53d..e070bdd 100644 --- a/server/internal/httpapi/response.go +++ b/server/internal/httpapi/response.go @@ -195,9 +195,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) { // decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。 // 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId, // 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。 +// +// 与 decodeJSONPresence 用同一把尺卡住请求体大小(maxJSONBodyBytes): +// 走这条更通用的路径的写接口有三十多个,此前一个上限都没有, +// 一次请求就能把几百 MB 读进内存(单进程服务,内存放大即整站不可用)。 func decodeJSON(r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(nil, r.Body, maxJSONBodyBytes) dec := json.NewDecoder(r.Body) if err := dec.Decode(dst); err != nil { + // 超限要报 413 而不是 400「不是合法 JSON」:前端据此提示「请求体过大」, + // 而不是让现场去查一个其实没写错的 JSON。 + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + return tooLarge("请求体超过上限") + } return badRequest("请求体不是合法 JSON", err.Error()) } return nil @@ -205,7 +216,7 @@ func decodeJSON(r *http.Request, dst any) error { // maxJSONBodyBytes 是普通 JSON 接口的请求体上限。 // 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限; -// 配合下面的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。 +// 配合 decodeJSONPresence 的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。 const maxJSONBodyBytes = 8 << 20 // keySet 是「请求体里真的出现过哪些键」。 diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index fa5d5ff..a5b6f0d 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" @@ -583,6 +584,10 @@ func preferComputedRank(a, b domain.RankedResult) bool { // 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft: // 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行, // 而且编辑页会把整组当成「已发布」锁死,补录无门。 +// +// 落在这一档里的还有「填了文本却没有成绩值」的草稿(客户端没算出 resultValue): +// 同样发不出去,但外表上像是录过了,所以发布时要单独计数并写进修订(countTextOnlyDrafts), +// 不能让它静默消失。 const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')` // publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。 @@ -675,6 +680,51 @@ func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bo return published == total, nil } +// eventFullyPublished 判断整小项是否「所有该出成绩的报名项都已发布」。 +// +// 判据与结算同源(scopeStats):退赛 / 缺赛 / 失格的报名项不进分母,因为它们本来就不等成绩。 +// 必须要求 total > 0——一条报名项都没有的小项没有任何成绩可发,标成「已发布」是假信息。 +func eventFullyPublished(ctx context.Context, q queryer, eventID int64) (bool, error) { + total, published, err := scopeStats(ctx, q, eventID, 0) + if err != nil { + return false, err + } + return total > 0 && published == total, nil +} + +// countTextOnlyDrafts 数出「这次发布带不上、看起来却已经录过」的草稿: +// 有成绩文本,却没有成绩值、没有手工名次、状态也还是 ok。 +// +// 这批行按 publishableResultCond 永远发不出去(缺的正是那个比较值),而发布接口照回 200: +// 客户端只送 resultText 时成绩就这样静默漏掉。纯空行(连文本都没有)不算—— +// 分批比赛里「还没跑」是正常状态,一起数进去只会淹没真正的问题。 +func countTextOnlyDrafts(ctx context.Context, q queryer, eventID, categoryID int64) (int, error) { + query := `SELECT COUNT(*) FROM results r + JOIN entries e ON e.id = r.entry_id + WHERE r.event_id = ? AND r.state = 'draft' + AND r.result_value IS NULL AND r.rank IS NULL AND r.status = 'ok' + AND TRIM(COALESCE(r.result_text, '')) <> ''` + args := []any{eventID} + if categoryID > 0 { + query += ` AND e.category_id = ?` + args = append(args, categoryID) + } + var n int + if err := q.QueryRowContext(ctx, query, args...).Scan(&n); err != nil { + return 0, internal(err) + } + return n, nil +} + +// skipNote 把「跳过多少条只填了文本的草稿」写成给现场看的一句话,没有跳过时为空串 +// (空串不落显眼文案,变更历史里也不会多出一行无用说明)。 +func skipNote(skipped int) string { + if skipped == 0 { + return "" + } + return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped) +} + // liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。 func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { var raw string @@ -723,6 +773,18 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil { return nil, internal(err) } + // 没被带上的「只有成绩文本」的草稿要报出来:它们按 publishableResultCond 永远发不出去 + // (既没有成绩值、也没有手工名次、状态还是 ok),而接口照样回 200。 + // 客户端只送 resultText 时成绩就这样静默漏掉,现场几乎发现不了;数出来写进本次修订 + // (变更历史会显示),并留一条服务端日志。纯空行不算——分批比赛里「还没跑」是正常状态。 + skipped, err := countTextOnlyDrafts(ctx, tx, in.EventID, catID) + if err != nil { + return nil, err + } + if skipped > 0 { + s.logger.Warn("发布跳过了只填成绩文本的草稿", + "eventId", in.EventID, "categoryId", catID, "count", skipped) + } live, err := liveOnPublishQ(ctx, tx) if err != nil { @@ -752,7 +814,20 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i return nil, err } } - if catID == 0 { + // 小项进度(events.status)里的 published 只有一种含义:整项都发完了。 + // 整项发布(catID == 0)由调用方明确声明,照旧直接写;按组别发布则要先确认整项所有 + // 该出成绩的报名项都已发布——录入页永远带 categoryId,少了这一步,「一组一组发完」的 + // 小项在界面上会一直停在「待录入」,侧栏与仪表盘的已发布计数也永远漏掉它。 + // 还没发完时一个字符都不写:一组发布 ≠ 整项已发布,把状态写成 published 只会制造 + // 「已发布」的假象(大屏 / 导出虽然不读这一列,人读)。 + done := catID == 0 + if !done { + done, err = eventFullyPublished(ctx, tx, in.EventID) + if err != nil { + return nil, err + } + } + if done { if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil { return nil, internal(err) } @@ -762,7 +837,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i if err != nil { return nil, err } - if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil { + if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, skipNote(skipped)); err != nil { return nil, err } if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { @@ -834,10 +909,16 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil { return nil, err } - if catID == 0 { - if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil { - return nil, internal(err) - } + // 小项进度与发布对称地退回:整项撤销到 finished(与既有行为一致); + // 按组别撤销只在整项此前被标成 published 时降级——这次撤销让整项不再是「全部已发布」, + // 状态不跟着退,界面就会写着「已发布」而其中一组其实已经撤回。 + // pending / ongoing 是人工设的进度,这里不覆盖。 + revert := `UPDATE events SET status = 'finished' WHERE id = ?` + if catID > 0 { + revert += ` AND status = 'published'` + } + if _, err := tx.ExecContext(ctx, revert, in.EventID); err != nil { + return nil, internal(err) } after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) @@ -1071,15 +1152,21 @@ func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action strin if err != nil { return internal(err) } - // 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。 + // 父节点 = 该小项当前的 MAX(id),即上一条版本:一个小项串成**一条链**。 + // 撤销后再发布也是接在链尾(新行 id 最大,就是上一条),**不会从撤销点分出分支**—— + // result_revisions 实际不是 DAG(P2-C06)。每个小项的第一条版本 parent_id 为 NULL。 var parentID sql.NullInt64 if err := tx.QueryRowContext(ctx, `SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil { return internal(err) } + // 这条 INSERT 不再写 ledger_ids:该列从来没有读取侧(变更历史只读 snapshot / action / + // message),前端却按它渲染「积分流水 N 条」,于是每次发布都在变更历史里显示 0 条—— + // 一句对用户可见的错信息。收口方向是连同前端那一栏一起删掉,写入侧先与它脱钩: + // 列上有 NOT NULL DEFAULT '[]',不写照样满足约束,删列迁移落地后这条语句也依然成立。 _, err = tx.ExecContext(ctx, - `INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message) - VALUES (?, ?, ?, ?, '[]', ?, ?)`, + `INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, actor_id, message) + VALUES (?, ?, ?, ?, ?, ?)`, parentID, eventID, action, string(snapshot), nullID(actorID), message) if err != nil { return internal(err) diff --git a/server/internal/httpapi/scoring_test.go b/server/internal/httpapi/scoring_test.go index f1cf6ec..0e0db36 100644 --- a/server/internal/httpapi/scoring_test.go +++ b/server/internal/httpapi/scoring_test.go @@ -3,6 +3,7 @@ package httpapi_test import ( "fmt" "net/http" + "strings" "testing" ) @@ -171,3 +172,157 @@ func TestVoidResultRewritesRemainingRanks(t *testing.T) { } } } + +// eventStatus 读小项进度(events.status)。 +func eventStatus(t *testing.T, env *testEnv, eventID int64) string { + t.Helper() + var s string + if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, eventID).Scan(&s); err != nil { + t.Fatalf("读取小项状态: %v", err) + } + return s +} + +// A07:result_revisions.ledger_ids 没有任何读取侧(变更历史只读 snapshot / action / message), +// 写入侧与它脱钩之后,列被迁移删掉也必须不影响发布记录修订——否则删列一上线就发不出成绩。 +func TestPublishRevisionWriteDoesNotNeedLedgerIdsColumn(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.exec(t, `ALTER TABLE result_revisions DROP COLUMN ledger_ids`) + + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + }, true); status != http.StatusOK { + t.Fatalf("删掉 ledger_ids 列后发布仍应 200,得到 %d %+v", status, e.Error) + } + assertCount(t, env, `SELECT COUNT(*) FROM result_revisions WHERE event_id = ?`, 1, ids.event) +} + +// A14:只有成绩文本的草稿按 publishableResultCond 永远发不出去,而接口照回 200。 +// 发布必须把「跳过几条」写进本次修订(变更历史直接显示 message),静默漏成绩才算被打破。 +func TestPublishReportsTextOnlyDrafts(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 第三条报名项连成绩都没录:它是正常的「还没跑」,不能被算进跳过的条数里。 + athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA) + entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athlete) + + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"}, + // 客户端只送了文本、没算出成绩值(resultValue 缺失)。 + {"entryId": ids.entryB, "resultText": "11.92", "unit": "s", "status": "ok"}, + {"entryId": entryC, "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + + // 只有文本的那条留在 draft,有值的那条上屏。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'published'`, 1, ids.entryA) + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ? AND state = 'draft'`, 1, ids.entryB) + + status, env2 := env.do(client, http.MethodGet, + fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读变更列表应 200,得到 %d %+v", status, env2.Error) + } + items := decodeData[map[string]any](t, env2)["items"].([]any) + if len(items) != 1 { + t.Fatalf("发布后应有 1 条修订,得到 %d", len(items)) + } + message, _ := items[0].(map[string]any)["message"].(string) + if !strings.Contains(message, "跳过 1 条") { + t.Fatalf("修订应写明跳过 1 条无值草稿(空行不算),得到 %q", message) + } +} + +// A15:录入页永远带 categoryId,按组别发布是常规路径。小项进度(events.status) +// 不能在只发完一组时就写 published,也不能在整项都发完后还停在原状态—— +// 后者会让界面一直显示「待录入」、侧栏与仪表盘的已发布计数永远漏掉这一项。 +func TestCategoryPublishAdvancesEventStatusWhenDone(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 第二个组别 + 两条报名项:整项共有 4 条该出成绩的报名项。 + cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '女子', 1, 1)`, ids.event) + athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA) + athD := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('赵敏', '女', ?, 1)`, ids.teamB) + entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamA, athC) + entryD := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, athD) + + save := func(categoryID int64, rows []map[string]any) { + t.Helper() + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, "categoryId": categoryID, "results": rows, + }, true); status != http.StatusOK { + t.Fatalf("组别 %d 存草稿应 200,得到 %d %+v", categoryID, status, e.Error) + } + } + publish := func(categoryID int64) { + t.Helper() + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": categoryID, + }, true); status != http.StatusOK { + t.Fatalf("组别 %d 发布应 200,得到 %d %+v", categoryID, status, e.Error) + } + } + + save(ids.category, []map[string]any{ + {"entryId": ids.entryA, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultText": "11.92", "resultValue": 11.92, "unit": "s", "status": "ok"}, + }) + save(cat2, []map[string]any{ + {"entryId": entryC, "resultText": "12.30", "resultValue": 12.30, "unit": "s", "status": "ok"}, + {"entryId": entryD, "resultText": "12.44", "resultValue": 12.44, "unit": "s", "status": "ok"}, + }) + + publish(ids.category) + if got := eventStatus(t, env, ids.event); got != "ongoing" { + t.Fatalf("只发完一个组不得把小项标成已发布,得到 %q", got) + } + + publish(cat2) + if got := eventStatus(t, env, ids.event); got != "published" { + t.Fatalf("整项所有组都发完后小项应标成 published,得到 %q", got) + } + + if status, e := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("撤销一个组应 200,得到 %d %+v", status, e.Error) + } + if got := eventStatus(t, env, ids.event); got != "finished" { + t.Fatalf("撤回一个组后小项不该再是已发布,得到 %q", got) + } +} diff --git a/server/internal/httpapi/session_revoke_test.go b/server/internal/httpapi/session_revoke_test.go new file mode 100644 index 0000000..d6d9c92 --- /dev/null +++ b/server/internal/httpapi/session_revoke_test.go @@ -0,0 +1,210 @@ +package httpapi_test + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +// countUserSessions 数某账号在库里的会话行。 +func countUserSessions(t *testing.T, env *testEnv, userID int64) int { + t.Helper() + var n int + if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil { + t.Fatalf("统计会话: %v", err) + } + return n +} + +// meRole 走 /api/auth/me 读当前身份:会话被撤之后这里会退回 guest。 +func meRole(t *testing.T, env *testEnv, client *http.Client) string { + t.Helper() + status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("读 /api/auth/me 应 200,得到 %d %+v", status, body.Error) + } + return decodeData[map[string]any](t, body)["role"].(string) +} + +// 会话失效的三条管理动作(重置密码 / 停用 / 自己改密)必须**当场**生效: +// 旧 Cookie 下一次请求就该被当成访客,库里那行会话也该没了。 +// 在此之前这些承诺只有 auth 包内的函数级断言,没有一条走 HTTP 的端到端用例—— +// 管理端 handler 漏调撤会话(或撤了却忘了清 Cookie)不会有任何东西变红。 +func TestAdminActionsInvalidateSessionsImmediately(t *testing.T) { + env := newTestEnv(t) + admin := adminClient(t, env) + uid := env.createUser("recorder", "recorder-pass", "recorder") + worker, _ := env.client() + env.login(worker, "recorder", "recorder-pass") + + if got := meRole(t, env, worker); got != "recorder" { + t.Fatalf("登录后身份应为 recorder,得到 %q", got) + } + + // ① 管理员重置密码:旧会话立即失效(旧 Cookie 直接当访客用),库里的行也没了。 + status, body := env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", uid), + map[string]any{"password": "new-recorder-pass"}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error) + } + if n := countUserSessions(t, env, uid); n != 0 { + t.Fatalf("重置密码后该用户的会话行应清空,库里还剩 %d 行", n) + } + if got := meRole(t, env, worker); got != "guest" { + t.Fatalf("重置密码后旧会话应退回 guest,得到 %q", got) + } + status, body = env.do(worker, http.MethodPost, "/api/teams", + map[string]any{"code": "X1", "name": "旧会话不该还能写"}, true) + if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("重置密码后旧会话的写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error) + } + + // 旧密码登不进去,新密码可以:证明失效的是会话,不是账号。 + fresh, _ := env.client() + if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login", + map[string]any{"username": "recorder", "password": "recorder-pass"}, false); status == http.StatusOK { + t.Fatalf("重置密码后旧密码不该还能登录,得到 %d", status) + } + env.login(fresh, "recorder", "new-recorder-pass") + if got := meRole(t, env, fresh); got != "recorder" { + t.Fatalf("新密码登录后身份应为 recorder,得到 %q", got) + } + + // ② 停用账号:刚建好的这条新会话也必须当场失效。 + status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", uid), + map[string]any{"active": false}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("停用账号应 200,得到 %d %+v", status, body.Error) + } + if n := countUserSessions(t, env, uid); n != 0 { + t.Fatalf("停用后该用户的会话行应清空,库里还剩 %d 行", n) + } + if got := meRole(t, env, fresh); got != "guest" { + t.Fatalf("停用后旧会话应退回 guest,得到 %q", got) + } + if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login", + map[string]any{"username": "recorder", "password": "new-recorder-pass"}, false); status == http.StatusOK { + t.Fatalf("停用后不该还能登录,得到 %d", status) + } +} + +// 自己改密:本人的会话也要当场作废,并明确告诉前端「重新登录」。 +// 只清 Cookie 不撤会话(或反过来)都算没做到——前者旧 Cookie 还能用,后者页面看着没退。 +func TestChangeOwnPasswordInvalidatesOwnSession(t *testing.T) { + env := newTestEnv(t) + uid := env.createUser("recorder", "recorder-pass", "recorder") + client, jar := env.client() + env.login(client, "recorder", "recorder-pass") + + // 改密前先把旧会话 token 记下来:改密之后要用它重放(模拟「Cookie 被抄走/浏览器还没收到清除指令」)。 + u, err := url.Parse(env.server.URL) + if err != nil { + t.Fatalf("解析测试服务地址: %v", err) + } + oldSession := "" + for _, c := range jar.Cookies(u) { + if c.Name == auth.CookieSession { + oldSession = c.Value + } + } + if oldSession == "" { + t.Fatalf("登录后 Cookie 里应有 %s", auth.CookieSession) + } + + status, body := env.do(client, http.MethodPost, "/api/auth/password", + map[string]any{"currentPassword": "recorder-pass", "newPassword": "brand-new-pass"}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("改密应 200,得到 %d %+v", status, body.Error) + } + if relogin, _ := decodeData[map[string]any](t, body)["relogin"].(bool); !relogin { + t.Fatalf("改密回执应带 relogin=true 引导重新登录,得到 %+v", decodeData[map[string]any](t, body)) + } + if n := countUserSessions(t, env, uid); n != 0 { + t.Fatalf("改密后自己的会话行应清空,库里还剩 %d 行", n) + } + + // 服务端下发了删除指令:浏览器不会再带着这枚 Cookie 发请求。 + for _, c := range jar.Cookies(u) { + if c.Name == auth.CookieSession && c.Value != "" && c.MaxAge >= 0 { + t.Fatalf("改密应答应清掉会话 Cookie,得到 %+v", c) + } + } + + // 旧 Cookie 手工塞回来重放也是访客:撤会话与清 Cookie 两件事必须都做到, + // 只做后者的话,任何抓到过旧 Cookie 的地方都还能继续用这个身份。 + replay, err := http.NewRequest(http.MethodGet, env.server.URL+"/api/auth/me", nil) + if err != nil { + t.Fatalf("构造重放请求: %v", err) + } + replay.AddCookie(&http.Cookie{Name: auth.CookieSession, Value: oldSession}) + resp, err := (&http.Client{}).Do(replay) + if err != nil { + t.Fatalf("重放旧 Cookie: %v", err) + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(raw), `"role":"guest"`) { + t.Fatalf("旧 Cookie 重放应被当成访客,得到 %d %s", resp.StatusCode, raw) + } +} + +// 滑动续期必须带着 Cookie 的 Max-Age 一起走,并且 Max-Age 跟的是**该会话的剩余寿命**, +// 不是当前有效期:有效期被改短之后,老会话仍按自己的到期时间下发(否则浏览器比服务端早丢 Cookie)。 +func TestMeRefreshesCookieMaxAgeFromSessionExpiry(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + + // 登录时会话到期 = 登录时刻 + 24h;把有效期改短成 1 小时(不动已存在的会话)。 + if status, body := env.do(client, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 1}, true); status != http.StatusOK { + t.Fatalf("把有效期改成 1 小时应 200,得到 %d %+v", status, body.Error) + } + stored := sessionExpiry(t, env, "admin") + + resp, err := client.Get(env.server.URL + "/api/auth/me") + if err != nil { + t.Fatalf("请求 /auth/me: %v", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + var session, csrf *http.Cookie + for _, c := range resp.Cookies() { + switch c.Name { + case auth.CookieSession: + session = c + case auth.CookieCSRF: + csrf = c + } + } + if session == nil || csrf == nil { + t.Fatalf("/auth/me 应重发会话与 CSRF Cookie(滑动续期要同步浏览器端的 Max-Age),得到会话 %+v / CSRF %+v", + session, csrf) + } + // 剩余寿命 = 库里那条会话的到期时间 - 现在(容许几秒误差)。 + want := int(time.Until(stored).Seconds()) + if diff := session.MaxAge - want; diff > 3 || diff < -3 { + t.Fatalf("会话 Cookie 的 Max-Age 应等于这条会话的剩余寿命 %d 秒,得到 %d", want, session.MaxAge) + } + // 关键对照:有效期已经改成 1 小时了,Max-Age 不能跟着它变成 3600。 + if session.MaxAge < 20*60*60 { + t.Fatalf("Max-Age 跟错了对象(疑似用了当前有效期而非会话到期时间):%d,库里到期时间是 %s", + session.MaxAge, stored.Format(time.RFC3339)) + } + // CSRF 不轮换:值必须与 Cookie 里原样一致,否则飞行中的写请求会集体 403。 + if _, err := url.Parse(env.server.URL); err != nil { + t.Fatalf("解析测试服务地址: %v", err) + } + if csrf.MaxAge != session.MaxAge { + t.Fatalf("CSRF Cookie 应与会话 Cookie 同一期限,得到 %d / %d", csrf.MaxAge, session.MaxAge) + } + if strings.TrimSpace(csrf.Value) == "" { + t.Fatalf("重发的 CSRF Cookie 不该是空值") + } +} diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go index 0e9c4f7..3f6bebf 100644 --- a/server/internal/middleware/middleware.go +++ b/server/internal/middleware/middleware.go @@ -194,7 +194,17 @@ func CSRF(logger *slog.Logger, next http.Handler) http.Handler { // 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号, // 因此对带 Origin/Referer 的登录请求单独做同源校验。 if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) { - intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败,请刷新页面重试", nil) + // 文案必须指向真因:这条拦截最常见的场景不是「浏览器缓存了旧页面」, + // 而是反向代理改写了 Host(浏览器带外部域名、服务端只看到回源地址), + // 照旧写「请刷新页面重试」会把现场引到永远试不好的方向。 + // detail 带上实际比对的两个值:现场照着改一行反代配置就能好。 + source := r.Header.Get("Origin") + if source == "" { + source = r.Header.Get("Referer") + } + intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", + "登录请求来源校验失败:本站 Origin 与服务端收到的 Host 不一致(反向代理通常改写 Host,需让它原样透传 Host 或透传 X-Forwarded-Host 并把代理加入可信代理)", + map[string]any{"source": source, "host": r.Host, "forwardedHost": r.Header.Get("X-Forwarded-Host")}) return } if User(r.Context()).Role == auth.RoleGuest { @@ -207,7 +217,10 @@ func CSRF(logger *slog.Logger, next http.Handler) http.Handler { cookie = c.Value } if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) { - intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请刷新页面重试", nil) + // 「请刷新页面重试」是错的:页面加载走的是 /auth/me,而它此前不会补发丢失的 CSRF Cookie, + // 刷新一百次也还是 403。当前唯一有效的自愈路径是退出后重新登录(登录会重新下发两枚 Cookie)。 + // 配套修复见 internal/httpapi/handlers_config.go 的 me:缺票时补发一枚新的。 + intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请退出后重新登录", nil) return } next.ServeHTTP(w, r) @@ -216,6 +229,23 @@ func CSRF(logger *slog.Logger, next http.Handler) http.Handler { // sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer), // 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。 +// +// 反代改写 Host 的部署(nginx `proxy_set_header Host 127.0.0.1:19540;`)下,浏览器带的 Origin 是 +// 外部域名、r.Host 是回源地址,两者永远不相等 → 所有人登录 403。这里在直连对端命中可信代理时, +// 额外采信 X-Forwarded-Host(代理透传的原 Host)。 +// +// 为什么这不等于「把同源校验放水到任意 Origin」: +// 1. Origin 由浏览器自动生成,跨站页面改不了它——攻击者页面发来的请求,Origin 仍是攻击者的域名; +// 2. X-Forwarded-Host 同样只有代理或非浏览器客户端能设:跨站 fetch/XHR 带自定义头会触发预检, +// 本服务不返回任何 Access-Control-* 头,预检必失败;HTML 表单压根设不了自定义头; +// 3. 能自行编造任意请求头的客户端(curl / 脚本)本来就没有受害者的 Cookie,CSRF 对它不成立, +// 给它放宽不改变任何安全结论; +// 4. 还要求直连对端在可信代理白名单里(与 ClientIP 同口径),挡住「绕过代理直连、自己塞头」的路径; +// 5. 采信的值仍必须与 Origin 相等,不是无条件放行——校验的本质(Origin 属于本站)没有变, +// 只是「本站」由 r.Host 一个候选变成 r.Host 或代理声明的原 Host。 +// +// 保守取舍:只做全等比较,不做「忽略端口」这类归一化。 +// nginx 的 $host 会去掉端口,部署在非默认端口时请用 `X-Forwarded-Host $http_host`。 func sameOrigin(r *http.Request) bool { raw := r.Header.Get("Origin") if raw == "" { @@ -228,7 +258,15 @@ func sameOrigin(r *http.Request) bool { if err != nil || parsed.Host == "" { return false } - return strings.EqualFold(parsed.Host, r.Host) + if strings.EqualFold(parsed.Host, r.Host) { + return true + } + if !isTrustedProxy(net.ParseIP(remoteHost(r.RemoteAddr))) { + return false + } + // 多值时取第一个:代理链上离客户端最近的那一跳写的才是浏览器真正用过的 Host。 + forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]) + return forwarded != "" && strings.EqualFold(parsed.Host, forwarded) } // RateLimitWrite 对已登录用户的写操作限流。 diff --git a/server/internal/middleware/proxy_origin_test.go b/server/internal/middleware/proxy_origin_test.go new file mode 100644 index 0000000..d052933 --- /dev/null +++ b/server/internal/middleware/proxy_origin_test.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +// withTrustedProxy 临时配置可信代理;可信代理是包级全局变量,测试结束必须恢复原状。 +func withTrustedProxy(t *testing.T, entries ...string) { + t.Helper() + t.Cleanup(func() { _ = SetTrustedProxies(nil) }) + if err := SetTrustedProxies(entries); err != nil { + t.Fatalf("配置可信代理: %v", err) + } +} + +// 反代改写 Host 的部署里,浏览器带的 Origin 是外部域名、r.Host 是回源地址, +// 直连对端命中可信代理时采信它透传的原 Host(X-Forwarded-Host)。 +func TestSameOriginAcceptsForwardedHostFromTrustedProxy(t *testing.T) { + withTrustedProxy(t, "10.0.0.0/8") + + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + req.RemoteAddr = "10.0.0.7:51234" // 直连对端 = 可信代理 + req.Host = "127.0.0.1:19540" // 代理改写了 Host + req.Header.Set("Origin", "http://scoreboard.local") + req.Header.Set("X-Forwarded-Host", "scoreboard.local") + if !sameOrigin(req) { + t.Fatal("可信代理透传的原 Host 与 Origin 一致时应放行") + } + + // 多值时只认第一个:代理链上离客户端最近的那一跳写的才是浏览器用过的 Host。 + req.Header.Set("X-Forwarded-Host", "scoreboard.local, inner.local") + if !sameOrigin(req) { + t.Fatal("多值 X-Forwarded-Host 应取第一个比较") + } + + // 放行的只是「代理透传的原 Host」,不是任意 Origin:跨站 Origin 仍然拦下。 + req.Header.Set("Origin", "http://evil.example") + if sameOrigin(req) { + t.Fatal("跨站 Origin 必须被拒(同源校验没有被放水成任意 Origin)") + } + + // 代理没透传原 Host 时,Host 与 Origin 不一致依旧拦下—— + // 这正是「代理只改了 Host、既没透传 Host 也没透传 X-Forwarded-Host」的现场, + // 修法在代理一侧(proxy_set_header Host $host),服务端不猜。 + req.Header.Set("Origin", "http://scoreboard.local") + req.Header.Set("X-Forwarded-Host", "") + if sameOrigin(req) { + t.Fatal("没有原 Host 可比时不能放行") + } +} + +// 直连对端不在可信代理白名单时,X-Forwarded-Host 一律不采信——代理头是客户端可伪造的, +// 与 ClientIP 同口径(只有可信代理的转发头才作数)。 +func TestSameOriginIgnoresForwardedHostFromUntrustedPeer(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + req.RemoteAddr = "203.0.113.9:5000" // 公网直连,不在白名单 + req.Host = "127.0.0.1:19540" + req.Header.Set("Origin", "http://scoreboard.local") + req.Header.Set("X-Forwarded-Host", "scoreboard.local") + if sameOrigin(req) { + t.Fatal("不可信对端塞进来的 X-Forwarded-Host 不能放行") + } + + // 真·同源(Host 就等于 Origin)不受影响,照常放行。 + req.Host = "scoreboard.local" + if !sameOrigin(req) { + t.Fatal("Host 与 Origin 一致时应放行") + } +} + +// 登录来源校验的提示不能再指向「刷新页面」:真因多半是反代改写 Host, +// 文案必须把现场引到代理配置上(错误方向的提示会让人反复刷新)。 +func TestLoginOriginFailureMessagePointsAtProxy(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := CSRF(nil, next) + + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) + req.Host = "127.0.0.1:19540" + req.Header.Set("Origin", "http://scoreboard.local") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("Host 与 Origin 不一致应 403,得到 %d", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "刷新页面") { + t.Fatalf("提示不该再指向「刷新页面」:%s", body) + } + if !strings.Contains(body, "Host") || !strings.Contains(body, "X-Forwarded-Host") { + t.Fatalf("提示应说明是 Host 与 Origin 不一致以及代理怎么改:%s", body) + } +} + +// CSRF 校验失败时刷新页面补不回丢失的 Cookie,提示必须指向唯一有效的自愈路径:退出后重新登录。 +func TestCSRFFailureMessagePointsAtRelogin(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := CSRF(nil, next) + + req := httptest.NewRequest(http.MethodPut, "/api/teams/1", nil) + req = req.WithContext(WithUser(req.Context(), + &auth.User{ID: 1, Username: "recorder", Role: auth.RoleRecorder})) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("缺 CSRF 头的写请求应 403,得到 %d", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "刷新页面") { + t.Fatalf("提示不该再指向「刷新页面」(刷新走的是 /auth/me,补不回 Cookie):%s", body) + } + if !strings.Contains(body, "请退出后重新登录") { + t.Fatalf("提示应指向「退出后重新登录」:%s", body) + } +} diff --git a/server/internal/middleware/recover_test.go b/server/internal/middleware/recover_test.go new file mode 100644 index 0000000..9ea5a98 --- /dev/null +++ b/server/internal/middleware/recover_test.go @@ -0,0 +1,145 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/logging" +) + +// panicOnce 第一次请求 panic,之后正常应答:这样一条链既能验「panic 被兜住」, +// 又能验「兜住之后同一条链照常服务」(panic 没有被吞成静默死循环、也没有把进程带走)。 +func panicOnce(secret string) http.Handler { + var hits int + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if hits == 1 { + panic(secret) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"ok":true}`)) + }) +} + +// internalErrorBody 是 500 响应的形状:与 httpapi 的统一错误体同构 +// (writeError 与 httpapi.writeAPIError 是两份实现,形状必须对得上,前端只按一种解)。 +type internalErrorBody struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Detail json.RawMessage `json:"detail"` + } `json:"error"` +} + +// 全仓此前没有任何「让 handler panic」的用例:Recover 写错状态码、写成 200、 +// 把 panic 原文(含栈、含内部数据)回给客户端,都没有东西会红。 +func TestRecoverReturnsInternalErrorAndKeepsServing(t *testing.T) { + const secret = "重算名次时下标越界:athleteID=8848" + chain := RequestID(Recover(slog.New(slog.NewTextHandler(io.Discard, nil)))(panicOnce(secret))) + + rec := httptest.NewRecorder() + chain.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/standings", nil)) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("panic 应回 500,得到 %d(body %s)", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { + t.Fatalf("500 的 Content-Type 应为 application/json; charset=utf-8,得到 %q", ct) + } + // 响应体必须是一份完整、可解析的统一错误体:半截 JSON 会让前端把它当网络错误。 + var body internalErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("500 响应体应为完整 JSON: %v\n%s", err, rec.Body.String()) + } + if body.OK { + t.Fatalf("500 不该是 ok:true,得到 %s", rec.Body.String()) + } + if body.Error.Code != "INTERNAL" || body.Error.Message != "服务端异常" { + t.Fatalf("500 形状应为 code=INTERNAL / message=服务端异常,得到 %+v", body.Error) + } + if body.Error.Detail != nil { + t.Fatalf("panic 不该回 detail(内部细节不外泄),得到 %s", body.Error.Detail) + } + // panic 原文与栈一律不回客户端:里面常有 id、SQL、路径。 + for _, leak := range []string{secret, "recover_test.go", "goroutine "} { + if strings.Contains(rec.Body.String(), leak) { + t.Fatalf("500 响应体泄露了 %q:%s", leak, rec.Body.String()) + } + } + + // 同一条链的下一个请求照常:panic 只结束这一个请求。 + rec2 := httptest.NewRecorder() + chain.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/standings", nil)) + if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), `"ok":true`) { + t.Fatalf("panic 之后同一条链应照常服务,得到 %d %s", rec2.Code, rec2.Body.String()) + } +} + +// 日志侧:handler panic 是排查类事件,必须落 interceptions/(不是 operations/), +// 且要带 panic 值、路径、request_id 与真实栈——只写一句「服务端异常」等于什么都没留。 +func TestRecoverLogsPanicToInterceptions(t *testing.T) { + const secret = "重算名次时下标越界:athleteID=8848" + dir := t.TempDir() + console := &bytes.Buffer{} + handler, closer := logging.New(logging.Options{ + Dir: dir, Level: slog.LevelInfo, Console: console, MaxSizeMB: 20, KeepFiles: 10, + }) + t.Cleanup(func() { _ = closer.Close() }) + + chain := RequestID(Recover(slog.New(handler))(panicOnce(secret))) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/recompute", nil) + chain.ServeHTTP(rec, req) + + inter := readLogFile(t, dir, logging.DirInterceptions) + for _, want := range []string{ + `"level":"ERROR"`, + "请求处理 panic", + secret, + `"path":"/api/recompute"`, + `"requestId":"`, // 请求 id 必须能对上现场那次请求 + `"stack":"`, + "recover_test.go", // 栈是这次 panic 的真实栈,不是一句占位文案 + } { + if !strings.Contains(inter, want) { + t.Fatalf("interceptions 日志应含 %q,得到:\n%s", want, inter) + } + } + // request_id 不能是空串:空串等于没有追踪键。 + if strings.Contains(inter, `"requestId":""`) { + t.Fatalf("panic 日志的 requestId 不该为空:\n%s", inter) + } + + // 拦截类不落 operations/:两类日志混在一起时,「有没有被拦过」就查不出来了。 + if ops := readLogFile(t, dir, logging.DirOperations); strings.Contains(ops, "请求处理 panic") { + t.Fatalf("panic 日志不该落 operations/,得到:\n%s", ops) + } + // 控制台仍要能看到(现场直接跑二进制时只有控制台)。 + if !strings.Contains(console.String(), "请求处理 panic") { + t.Fatalf("控制台应看到 panic 日志,得到:\n%s", console.String()) + } +} + +// readLogFile 读某类日志今天的文件内容;文件不存在返回空串。 +func readLogFile(t *testing.T, dir, category string) string { + t.Helper() + path := filepath.Join(dir, category, category+"-"+time.Now().Format("20060102")+".log") + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatalf("读日志 %s: %v", path, err) + } + return string(raw) +} diff --git a/server/internal/realtime/changes.go b/server/internal/realtime/changes.go index c3664e0..3bcaad2 100644 --- a/server/internal/realtime/changes.go +++ b/server/internal/realtime/changes.go @@ -4,11 +4,32 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" ) +// errDBClosed 是 database/sql 在连接池已关闭时返回的错误。 +// +// 标准库里这个错误是**未导出**的(database/sql 的 errDBClosed),拿不到变量,只能按原文认。 +// httpapi 包里有一份同口径的实现(那一份也导不出来,实时层又依赖不了 httpapi——会成环), +// 两份必须保持同一套语义:库已关闭 = 恢复/重启动作的中间状态,不是故障。 +var errDBClosed = errors.New("sql: database is closed") + +// dbClosed 判定错误是否只是「库已经被关掉」。 +// +// 只认原文完全相等的那一层(含被 %w 包过的),不按子串匹配:别的读库失败(锁超时、 +// SQL 写错、库损坏)都得继续按 WARN/ERROR 报出来,不能被这条静默规则顺手吞掉。 +func dbClosed(err error) bool { + for e := err; e != nil; e = errors.Unwrap(e) { + if e.Error() == errDBClosed.Error() { + return true + } + } + return false +} + // currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。 // NewHub 允许 db 为 nil(降级/测试),此时退回内存版本,避免空指针 panic。 func (h *Hub) currentVersion(ctx context.Context) (int64, error) { @@ -26,12 +47,18 @@ func (h *Hub) currentVersion(ctx context.Context) (int64, error) { } // currentVersionCached 在数据库不可用时退回内存里的版本,保证心跳与广播不会因一次查询失败而中断。 +// 「库已关闭」(恢复/重启窗口)只记 Debug:那不是故障,而且它每个心跳周期都会发生一次, +// 记 WARN 会让现场日志刷成一片红。 func (h *Hub) currentVersionCached(ctx context.Context) int64 { if h.db == nil { return h.Version() } v, err := h.currentVersion(ctx) if err != nil { + if dbClosed(err) { + h.log.Debug("读取版本号:库已关闭(恢复/重启中),沿用内存版本", "version", h.Version()) + return h.Version() + } h.log.Warn("读取版本号失败,沿用内存版本", "err", err, "version", h.Version()) return h.Version() } diff --git a/server/internal/realtime/client.go b/server/internal/realtime/client.go index 1ebed22..5cc1006 100644 --- a/server/internal/realtime/client.go +++ b/server/internal/realtime/client.go @@ -51,10 +51,13 @@ type client struct { // 丢了就可能让那块屏一直停在旧数据上,直到下一条变更到来。 snapshotPending atomic.Bool slow atomic.Bool + // lastSeen 是最近一次真正收到对端帧的时间(unix 纳秒),含普通数据帧与 ping 的 pong。 + // 「这条连接还活着吗」只认它:服务端自己发过什么、写过几次 socket 都不算证据。 + lastSeen atomic.Int64 } func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *client { - return &client{ + c := &client{ hub: h, conn: conn, remoteAddr: remoteAddr, @@ -65,9 +68,36 @@ func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) * sessionID: id.SessionID, settingsAdmin: id.SettingsAdmin, } + // 建立连接本身就是一次「听到对端」:不初始化的话,存活探测会把刚连上的 + // 大屏当成已经静默很久,第一条 ping 立刻发出去。 + c.markSeen(time.Now()) + return c } -// stop 只负责让两条 goroutine 退出;真正的关闭动作由 serve 统一执行, +// markSeen 记下「刚刚听到了对端」。 +func (c *client) markSeen(now time.Time) { c.lastSeen.Store(now.UnixNano()) } + +// lastSeenAt 返回最近一次听到对端的时间。 +func (c *client) lastSeenAt() time.Time { return time.Unix(0, c.lastSeen.Load()) } + +// instanceIDValue 读实例 id;调用方拿到的是快照值,不持有锁。 +func (c *client) instanceIDValue() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.instanceID +} + +// isDone 判断本连接是否已经进入关闭流程。 +func (c *client) isDone() bool { + select { + case <-c.done: + return true + default: + return false + } +} + +// stop 只负责让读写与存活探测三条 goroutine 退出;真正的关闭动作由 serve 统一执行, // 避免在持有广播锁时做可能阻塞的关闭握手。 func (c *client) stop(code websocket.StatusCode, reason string) { c.closeOnce.Do(func() { @@ -135,12 +165,21 @@ func (c *client) sendRaw(msg []byte) bool { } } -// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。 +// readLoop 解析 C→S 消息;握手必须在 helloTimeout 内完成,否则断开, +// 防止「连上就不说话」的空连接一直占着 goroutine 与 fd。 // 读写都在独立 goroutine 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。 func (c *client) readLoop(ctx context.Context) { defer c.stop(websocket.StatusNormalClosure, "") defer c.guardLoop("读") + // 握手 deadline 在进入循环时算一次,整个「未握手」阶段共用它。 + // + // 以前是每轮 Read 都新建一个 WithTimeout:只要对端在超时前随便发一帧(哪怕是个 + // 客户端不认识的事件),计时就被重置,未握手的连接可以无限期挂着—— + // 现场用脚本反复重连就能堆出一堆永不释放的连接。绝对 deadline 让「打开连接却不 + // hello」在固定时间后必然被断开;已握手的连接不受它限制。 + helloDeadline := time.Now().Add(c.hub.helloTimeout()) + for { c.mu.Lock() greeted := c.greeted @@ -149,7 +188,7 @@ func (c *client) readLoop(ctx context.Context) { readCtx := ctx var cancel context.CancelFunc if !greeted { - readCtx, cancel = context.WithTimeout(ctx, c.hub.helloTimeout()) + readCtx, cancel = context.WithDeadline(ctx, helloDeadline) } _, data, err := c.conn.Read(readCtx) if cancel != nil { @@ -158,10 +197,84 @@ func (c *client) readLoop(ctx context.Context) { if err != nil { return } + // 收到任何一帧都是「对端还活着」的证据,与它是不是合法事件无关。 + c.markSeen(time.Now()) c.handleMessage(ctx, data) } } +// livenessLoop 定期求证对端还在不在:静默(没有任何 C→S 帧)超过一个探测周期就发一次 +// WebSocket ping 并等 pong,连续 maxProbeFailures 次等不到就断开这条连接。 +// +// 为什么必须单独探:大屏在数据没变时只收不发(hello 只在连接与翻页时发、ack 只在应用 +// 变更时发),所以「没有上行帧」不等于掉线,不能当判据;而 TCP 层面「连接对象还在」 +// 什么也证明不了——拔网线、断电、机器休眠都不会让服务端立刻收到错误,内核缓冲会让写 +// 一直“成功”,注册表里的「在线」就会永远挂着那块已经不在的屏。 +// ping/pong 是唯一的真实往返,且由浏览器在网络层自动应答,不需要前端改代码。 +func (c *client) livenessLoop(ctx context.Context) { + defer c.guardLoop("存活探测") + + failures := 0 + for { + interval := c.hub.livenessIntervalValue() + if interval <= 0 { + // <= 0 表示关闭探测,只给「单独验证某个判定口径」的用例用: + // 生产路径上 NewHub 一定给了默认值,不会走到这里。 + return + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-c.done: + timer.Stop() + return + case <-timer.C: + } + + if time.Since(c.lastSeenAt()) < interval { + // 静默期内收到过真实上行帧:这条连接有人,不必多问一句。 + failures = 0 + continue + } + if c.probePeer(ctx) { + failures = 0 + continue + } + if ctx.Err() != nil || c.isDone() { + return + } + failures++ + if failures < maxProbeFailures { + // 一次不回应可能只是对端正忙(例如正在收一份大快照,写锁被占住), + // 再给一个周期;连续失败才说明这条连接上已经没有人了。 + continue + } + c.hub.log.Info("实时连接长时间无响应(未回 pong),按掉线断开", + "remote", c.remoteAddr, "instance", c.instanceIDValue(), + "silent", time.Since(c.lastSeenAt()).Truncate(time.Second).String()) + c.stop(unresponsiveCloseCode, "连接无响应") + return + } +} + +// probePeer 发一次 ping 并等 pong;返回 true 表示对端应答了。 +func (c *client) probePeer(ctx context.Context) bool { + pingCtx, cancel := context.WithTimeout(ctx, c.hub.pongTimeoutValue()) + defer cancel() + + if err := c.conn.Ping(pingCtx); err != nil { + c.hub.log.Debug("存活探测未收到 pong", "remote", c.remoteAddr, "err", err) + return false + } + now := time.Now() + c.markSeen(now) + // 这次往返是真实的客户端应答,注册表的「最后活跃」也该跟着走。 + c.hub.touchInstance(c, now) + return true +} + func (c *client) handleMessage(ctx context.Context, data []byte) { var env envelope if err := json.Unmarshal(data, &env); err != nil { diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index 60c2ac5..da83f25 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -17,7 +17,10 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" ) -// 默认参数;现场可通过 Set* 调整。 +// 默认参数。 +// +// 注意:Set* 目前只有测试在调用——现场调优要求先把它们接到 config.yaml / main.go 上, +// 那是本包之外的决定,所以不要照着 Set* 的存在去宣称「现场可调」。 const ( defaultHeartbeatInterval = 10 * time.Second defaultMaxCatchup = 500 @@ -26,6 +29,19 @@ const ( defaultWriteTimeout = 10 * time.Second defaultCloseTimeout = 2 * time.Second defaultSendBuffer = 64 + // defaultLivenessInterval 是「一点客户端帧都没收到」的静默期上限:超过它就发一次 + // WebSocket ping 求证对端还在不在(见 client.livenessLoop)。 + // + // 不能把「静默」直接当掉线:大屏在数据没变时本来就不上行。取 15s,配合 + // maxProbeFailures = 2 与 defaultPongTimeout = 5s,真正掉线的屏最迟 + // 2 × (15s + 5s) = 40s 内被判定并断开;稳态下每块屏每分钟只多发 4 个 ping 帧。 + defaultLivenessInterval = 15 * time.Second + // defaultPongTimeout 是等一次 pong 的超时。ping 帧本身要走写锁,正常浏览器在 + // 网络层立刻应答,5s 已经宽裕。 + defaultPongTimeout = 5 * time.Second + // maxProbeFailures 是连续多少次探测失败才断开:单次失败可能只是对端在忙 + // (正在收一份大快照,写锁被占住),连续失败才说明连接上没有人了。 + maxProbeFailures = 2 // defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。 // // 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒: @@ -43,6 +59,10 @@ const ( // 不能用 1008:前端把所有 1008 当「登录失效」并永久停止重连,慢客户端被踢后大屏会冻结。 const slowCloseCode = websocket.StatusCode(4000) +// unresponsiveCloseCode 是踢掉「不回 pong」的连接时使用的关闭码。同样避开 1008: +// 那块屏只是掉线了,重连回来就能补上数据,不该被引导去重新登录。 +const unresponsiveCloseCode = websocket.StatusCode(4001) + // Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。 // // 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更, @@ -76,6 +96,11 @@ type Hub struct { allowedOrigins []string // snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。 snapshotMinInterval time.Duration + // livenessInterval 是「一点客户端帧都没收到」的静默期上限;pongTimeout 是等一次 + // pong 的超时。两者只影响存活探测(见 client.livenessLoop),默认值见常量区注释; + // livenessInterval <= 0 表示关闭探测(只有测试会这么设)。 + livenessInterval time.Duration + pongTimeout time.Duration // sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。 sessionChecker SessionChecker } @@ -108,6 +133,8 @@ func NewHub(db *sql.DB, log *slog.Logger) *Hub { helloTO: defaultHelloTimeout, writeTO: defaultWriteTimeout, snapshotMinInterval: defaultSnapshotMinInterval, + livenessInterval: defaultLivenessInterval, + pongTimeout: defaultPongTimeout, } if db != nil { if v, err := h.currentVersion(context.Background()); err == nil { @@ -159,7 +186,11 @@ func (h *Hub) setVersion(v int64) { h.mu.Unlock() } -// SetHeartbeatInterval 调整心跳间隔并立即生效;用于现场调优与测试。 +// SetHeartbeatInterval 调整心跳间隔并立即生效。 +// +// 唯一调用方是测试(把心跳调快,好在毫秒级跑完「会话撤销即断开」这类用例); +// 生产路径用的是 defaultHeartbeatInterval。要做成现场可调,得先在 main.go 里 +// 从 config.yaml 读出来再调这里——本包不读配置,也没有别的消费方。 func (h *Hub) SetHeartbeatInterval(d time.Duration) { if d <= 0 { return @@ -174,7 +205,7 @@ func (h *Hub) SetHeartbeatInterval(d time.Duration) { } } -// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。 +// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。同样只有测试在调用。 func (h *Hub) SetMaxCatchup(n int) { if n <= 0 { return @@ -184,7 +215,7 @@ func (h *Hub) SetMaxCatchup(n int) { h.mu.Unlock() } -// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。 +// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。同样只有测试在调用。 func (h *Hub) SetSlowLag(n int64) { h.mu.Lock() h.slowLag = n @@ -261,6 +292,18 @@ func (h *Hub) writeTimeout() time.Duration { return h.writeTO } +func (h *Hub) livenessIntervalValue() time.Duration { + h.mu.RLock() + defer h.mu.RUnlock() + return h.livenessInterval +} + +func (h *Hub) pongTimeoutValue() time.Duration { + h.mu.RLock() + defer h.mu.RUnlock() + return h.pongTimeout +} + func (h *Hub) allowedOriginsValue() []string { h.mu.RLock() defer h.mu.RUnlock() @@ -336,9 +379,11 @@ func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) { defer cancel() var loops sync.WaitGroup - loops.Add(2) + loops.Add(3) go func() { defer loops.Done(); c.readLoop(ctx) }() go func() { defer loops.Done(); c.writeLoop(ctx) }() + // 存活探测:服务端「连接对象还在」不等于对端还在,注册表的在线判定靠这条循环求证。 + go func() { defer loops.Done(); c.livenessLoop(ctx) }() <-c.done // 先做关闭握手,再取消读上下文:coder/websocket 在读上下文被取消时会直接 @@ -487,6 +532,15 @@ func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) { cur, err := h.currentVersion(ctx) if err != nil { + if dbClosed(err) { + // 恢复/重启窗口(库已经被关掉、文件还没换完)里的新连接不能踢: + // 现场看到的是「每块屏刚连上就被 1011 打回、日志一屏 ERROR」,像恢复把系统 + // 搞坏了。与 httpapi/realtime.go 的降级口径一致——这类错误只记 Debug, + // 连接留着(心跳照发,客户端看门狗不会误判),等库回来后的下一次 + // resync / 重连自然就能拿到数据。 + h.log.Debug("跳过首包:库已关闭(恢复/重启中)", "remote", c.remoteAddr, "err", err) + return + } h.log.Error("读取版本号失败,无法下发首包", "err", err) c.stop(websocket.StatusInternalError, "版本号不可用") return @@ -584,6 +638,13 @@ func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool { func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) { snap, err := h.Snapshot(ctx) if err != nil { + if dbClosed(err) { + // 同 sendInitial:恢复窗口里「快照读不出来」不是故障,是停机动作的一部分。 + // 这里既不能记 ERROR,也不能拿 1011 把大屏踢回去重连(那正是现场 + // 「恢复把系统搞坏了」的观感来源);连接留着,心跳继续。 + h.log.Debug("跳过整库快照:库已关闭(恢复/重启中)", "remote", c.remoteAddr, "err", err) + return + } h.log.Error("构建快照失败", "err", err) c.stop(websocket.StatusInternalError, "快照不可用") return @@ -604,6 +665,10 @@ func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) { func (h *Hub) Broadcast(changes []Change) { h.fanout("", changes) } // BroadcastTo 把变更推给指定房间(如 RoomAdmin、AdminRoom(uid))。 +// +// 同样只有测试在调用:生产的推送要么是 Broadcast(所有已握手连接),要么是 +// SendCommandTo(大屏指令)。保留它是为了让「房间」这层语义有可用的出口, +// 删掉或接到生产调用方都要连 devdocs/05 §1 一起改(审查清单 C36)。 func (h *Hub) BroadcastTo(room string, changes []Change) { h.fanout(room, changes) } // fanout 在 sendMu 下按版本去重后下发:已经发过的版本不再重复投递。 @@ -701,10 +766,18 @@ func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int, return h.onlineDisplayCount(), nil } + // 归一化只做一次,并且**匹配与写注册表共用同一个值**:管理端从表格里带过来的 id + // 常有多余空白(粘贴/换行),以前匹配用 TrimSpace、写「当前页」用原值, + // 结果是指令真的发到了那块屏,注册表里的当前页却停在旧值上, + // 管理端下一次轮询又把界面上刚切过去的页显示回来。 wanted := make(map[string]bool, len(targets)) + normalized := make([]string, 0, len(targets)) for _, id := range targets { if id = strings.TrimSpace(id); id != "" { - wanted[id] = true + if !wanted[id] { + wanted[id] = true + normalized = append(normalized, id) + } } } @@ -723,7 +796,7 @@ func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int, h.sendMu.Unlock() if cmd.Action == CmdGoto { - h.setInstancePage(pageFromPayload(cmd.Payload), targets) + h.setInstancePage(pageFromPayload(cmd.Payload), normalized) } return len(matched), nil } @@ -819,7 +892,10 @@ func (h *Hub) beat() { continue } c.sendEvent(EventHeartbeat, data) - h.touchInstance(c, now) + // 只同步 ack / 慢标记,**不刷 lastHeartbeat**:注册表里的「在线」与「最后活跃」 + // 必须来自对端。服务端每 10s 自己给自己盖一个时间戳,会让拔了网线的屏一直 + // 显示在线、指令发过去还报「命中 N 块屏」。真正的存活求证在 livenessLoop。 + h.syncInstance(c) h.checkSlow(c, version) if c.snapshotPending.Load() { diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go index 9a6636d..142c7a5 100644 --- a/server/internal/realtime/hub_test.go +++ b/server/internal/realtime/hub_test.go @@ -1,6 +1,7 @@ package realtime import ( + "bytes" "context" "database/sql" "encoding/json" @@ -13,6 +14,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "sync/atomic" "testing" "time" @@ -50,9 +52,17 @@ func newTestEnvAs(t *testing.T, u *auth.User) *testEnv { // *auth.Session:会话复查(撤销即断开)要按会话 id 判定,测试必须能给出这一条。 func newTestEnvAsSession(t *testing.T, u *auth.User, sessionID string) *testEnv { t.Helper() + return newTestEnvFull(t, u, sessionID, io.Discard) +} + +// newTestEnvFull 与 newTestEnvAsSession 相同,但把实时层的日志写进调用方给的 writer, +// 且放行 Debug:需要断言「这条路径不该刷 WARN/ERROR」的用例(如恢复窗口降级)用它。 +// 其余用例传 io.Discard,行为与以前一致。 +func newTestEnvFull(t *testing.T, u *auth.User, sessionID string, logw io.Writer) *testEnv { + t.Helper() db := newTestDB(t) - h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))) + h := NewHub(db, slog.New(slog.NewTextHandler(logw, &slog.HandlerOptions{Level: slog.LevelDebug}))) handler := h.Handler() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := middleware.WithUser(r.Context(), u) @@ -934,6 +944,40 @@ func TestSendCommandTargetsMixedOnlineOffline(t *testing.T) { } } +// targets 里带空白(管理端从表格/粘贴里带过来的 id)时,匹配与「当前页」写入必须用 +// 同一个归一化后的值:以前匹配走 TrimSpace、写注册表用原值,结果是指令真的发到了那块屏, +// 注册表里的当前页却停在旧值上,管理端下一次轮询又把界面上刚切过去的页显示回来。 +func TestSendCommandTargetsTrimsWhitespaceForPageRegistry(t *testing.T) { + env := newTestEnv(t) + a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a", Page: "standings"}) + b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b", Page: "standings"}) + a.recvEvent(EventSnapshot) + b.recvEvent(EventSnapshot) + + cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"}) + if err != nil { + t.Fatalf("构造 goto: %v", err) + } + matched, err := env.hub.SendCommandToInstances([]string{" screen-a \n"}, cmd) + if err != nil { + t.Fatalf("定向下发: %v", err) + } + if matched != 1 { + t.Fatalf("带空白的 id 应命中 1 个实例,得到 %d", matched) + } + if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto { + t.Fatalf("被点名的实例应收到 goto,得到 %s", got.Action) + } + + insts := env.hub.Instances() + if got := instancePage(insts, "screen-a"); got != "medals" { + t.Fatalf("被点名实例的当前页应更新为 medals,得到 %q", got) + } + if got := instancePage(insts, "screen-b"); got != "standings" { + t.Fatalf("未被点名实例的当前页不该变,得到 %q", got) + } +} + // 非法 action 走定向通道要报错,不能静默丢掉。 func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) { env := newTestEnv(t) @@ -1399,3 +1443,237 @@ func TestPendingSnapshotFlushIsPerConnection(t *testing.T) { a.noSnapshotWithin(300 * time.Millisecond) b.noSnapshotWithin(300 * time.Millisecond) } + +// ---------- 在线判定 / 握手期限 / 恢复窗口降级 ---------- + +// syncBuffer 是带锁的日志缓冲:日志由实时层的多条 goroutine 写、用例在主 goroutine 读。 +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// tune 直接改内部参数:这些值只影响超时,用例需要的是毫秒级跑完, +// 而 Set* 那一族是留给现场调优的(且本包不该再为测试多开一个导出 API)。 +func (e *testEnv) tune(fn func(h *Hub)) { + e.hub.mu.Lock() + defer e.hub.mu.Unlock() + fn(e.hub) +} + +// waitForWithin 与 waitFor 相同,只是等待窗口由调用方给:有的用例要等一次完整的 +// 服务端关闭握手(serve 里最多 2s)。 +func waitForWithin(t *testing.T, what string, d time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("等待条件超时: %s", what) +} + +func instanceOnline(insts []Instance, id string) bool { + for _, st := range insts { + if st.ID == id { + return st.Online + } + } + return false +} + +// trySend 发送一帧并在失败时返回错误:用例要靠它判断「服务端已经关掉这条连接」 +// (普通 send 会直接 Fatal,那条路径在这里是预期结果而不是失败)。 +func (c *wsClient) trySend(event string, data any) error { + msg, err := encode(event, data) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(c.ctx, 2*time.Second) + defer cancel() + return c.conn.Write(ctx, websocket.MessageText, msg) +} + +// muteConn 是一条「连上就不再读」的客户端:它的 TCP 连接还在,但永远不会回应 pong—— +// 这正是拔网线 / 断电 / 机器休眠之后,服务端那一侧看到的样子。 +type muteConn struct { + t *testing.T + ctx context.Context + conn *websocket.Conn +} + +func dialMute(t *testing.T, env *testEnv, hello helloPayload) *muteConn { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(env.srv.URL, "http")+"/ws", nil) + if err != nil { + cancel() + t.Fatalf("连接 /ws: %v", err) + } + t.Cleanup(func() { + _ = conn.CloseNow() + cancel() + }) + + c := &muteConn{t: t, ctx: ctx, conn: conn} + if err := c.send(EventHello, hello); err != nil { + t.Fatalf("发送 hello: %v", err) + } + return c +} + +func (c *muteConn) send(event string, data any) error { + msg, err := encode(event, data) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() + return c.conn.Write(ctx, websocket.MessageText, msg) +} + +// 「在线」必须反映真的还活着:连接对象还在、但对端已经不应答的屏不能一直算命中。 +// 判据是真实的 ping/pong 往返,不是服务端自己刷的时间戳。 +func TestInstanceGoesOfflineWhenPeerStopsAnswering(t *testing.T) { + env := newTestEnv(t) + env.tune(func(h *Hub) { + h.livenessInterval = 80 * time.Millisecond + h.pongTimeout = 60 * time.Millisecond + }) + + // 失联的屏:握手正常,之后一个字节都不回。 + dialMute(t, env, helloPayload{Role: RoleDisplay, InstanceID: "mute-1", Page: "standings"}) + waitFor(t, "失联实例登记在线", func() bool { + insts := env.hub.Instances() + return len(insts) == 1 && insts[0].Online + }) + + // 正常的大屏同时挂着:它一直在收(于是自动回 pong),绝不能被误判成离线。 + alive := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "alive-1", Page: "standings"}) + alive.recvEvent(EventSnapshot) + + waitForWithin(t, "失联实例被判离线", 5*time.Second, func() bool { + return !instanceOnline(env.hub.Instances(), "mute-1") + }) + if !instanceOnline(env.hub.Instances(), "alive-1") { + t.Fatal("正常应答的实例不该被判离线") + } + + cmd, err := NewDisplayCommand(CmdReload, nil) + if err != nil { + t.Fatalf("构造 reload: %v", err) + } + if matched, err := env.hub.SendCommandToInstances([]string{"mute-1"}, cmd); err != nil || matched != 0 { + t.Fatalf("失联实例不该再计入命中,得到 %d err=%v", matched, err) + } + // 在线的屏不受影响:它的连接与服务端之间一切照旧。 + if ev, _, ok := alive.recvWithin(200 * time.Millisecond); ok { + t.Fatalf("正常实例不该收到额外消息,得到 %q", ev) + } +} + +// 握手超时是**绝对** deadline:以前每轮 Read 都重开一个 helloTimeout 窗口, +// 没握手的连接只要在窗口内随便发一帧就能无限期挂着(脚本重连能堆出一片空连接)。 +func TestHelloDeadlineIsAbsolute(t *testing.T) { + env := newTestEnv(t) + env.tune(func(h *Hub) { h.helloTO = 300 * time.Millisecond }) + + // 不 hello,只反复喂合法但无关的帧(ack)。 + c := env.dial(t, nil) + + closed := false + deadline := time.Now().Add(1500 * time.Millisecond) + for time.Now().Before(deadline) && !closed { + select { + case msg, ok := <-c.msgs: + if !ok || msg.err != nil { + closed = true + } + case <-time.After(60 * time.Millisecond): + // 写失败说明服务端已经关了这条连接,同样算「已断开」。 + closed = c.trySend(EventAck, ackPayload{}) != nil + } + } + if !closed { + t.Fatal("未握手的连接必须在绝对 deadline(300ms)后被断开,而不是每收一帧就重置计时") + } +} + +// 心跳循环不能替客户端盖「最后活跃」的章:一块不发任何上行帧的大屏,它的 +// LastHeartbeat 必须停在真实收到帧的那一刻,否则注册表里的「在线/最后活跃」 +// 全是服务端自己说的,拔了网线的屏也会显示成刚刚活跃(这正是 A24 的根因)。 +func TestHeartbeatDoesNotRefreshActivityByItself(t *testing.T) { + env := newTestEnv(t) + env.hub.SetHeartbeatInterval(30 * time.Millisecond) + // 关掉存活探测:这条用例只验证「时间戳的口径」,探测本身另有用例。 + env.tune(func(h *Hub) { h.livenessInterval = 0 }) + + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + c.recvEvent(EventSnapshot) + + insts := env.hub.Instances() + if len(insts) != 1 { + t.Fatalf("实例数应为 1,得到 %d", len(insts)) + } + before := insts[0].LastHeartbeat + + // 连收 5 个心跳,期间客户端一个上行帧都不发(它只读)。 + for i := 0; i < 5; i++ { + c.recvEvent(EventHeartbeat) + } + if got := env.hub.Instances()[0].LastHeartbeat; !got.Equal(before) { + t.Fatalf("服务端自己发心跳不能把「最后活跃」往前推:%v → %v", before, got) + } +} + +// 恢复窗口(库已经关掉、文件还没换完)里实时层必须安静下来:不刷 WARN/ERROR, +// 也不拿 1011 把刚连上或正在重连的屏踢回去——现场会把那读成「恢复把系统搞坏了」。 +// 降级口径与 httpapi/realtime.go 的 dbClosed 一致:只记 Debug,连接留着、心跳照发。 +func TestRecoveryWindowDegradesQuietly(t *testing.T) { + logs := &syncBuffer{} + env := newTestEnvFull(t, &auth.User{Role: auth.RoleGuest}, "", logs) + env.hub.SetHeartbeatInterval(40 * time.Millisecond) + env.hub.SetSnapshotMinInterval(0) + + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + c.recvEvent(EventSnapshot) + + // 模拟恢复动作的中间状态:目标库被关掉(换文件前的一刻)。 + if err := env.db.Close(); err != nil { + t.Fatalf("关闭赛事库: %v", err) + } + + // 大屏想补数据(resync):这条路径在恢复窗口里必然读不到库。 + c.send(EventResync, struct{}{}) + // 连接必须留着,心跳照常下发(被 1011 打回时 recvWithin 会直接 Fatal)。 + if ev, _, ok := c.recvWithin(2 * time.Second); !ok || ev != EventHeartbeat { + t.Fatalf("恢复窗口里已连接的屏应保持在线并继续收心跳,得到 %q ok=%v", ev, ok) + } + // 恢复窗口里新连上的屏同理:首包拿不到也不能被踢。 + fresh := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d2"}) + if ev, _, ok := fresh.recvWithin(2 * time.Second); !ok || ev != EventHeartbeat { + t.Fatalf("恢复窗口里新连接应保持并收到心跳,得到 %q ok=%v", ev, ok) + } + + got := logs.String() + if strings.Contains(got, "level=ERROR") || strings.Contains(got, "level=WARN") { + t.Fatalf("恢复窗口里不该出现 WARN/ERROR 日志:\n%s", got) + } + if !strings.Contains(got, "库已关闭") { + t.Fatalf("恢复窗口应留下 Debug 级的降级记录(否则真出问题时无从查起):\n%s", got) + } +} diff --git a/server/internal/realtime/instances.go b/server/internal/realtime/instances.go index 788fb37..e891ea6 100644 --- a/server/internal/realtime/instances.go +++ b/server/internal/realtime/instances.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "sort" "strconv" + "strings" "time" ) @@ -111,7 +112,9 @@ func (h *Hub) releaseDisplayID(id string, c *client) { st.slow = c.slow.Load() } -// touchInstance 在收到客户端消息或下发心跳时刷新「最后心跳」。 +// touchInstance 在收到客户端消息或探测到 pong 时刷新「最后活跃」。 +// now 必须是**收到对端帧的时间**,不能是服务端自己想盖的章:这个字段既决定注册表 +// 里的「最后心跳」,也是离线实例清理的计时起点(见 pruneInstancesLocked)。 func (h *Hub) touchInstance(c *client, now time.Time) { c.mu.Lock() id := c.instanceID @@ -129,9 +132,32 @@ func (h *Hub) touchInstance(c *client, now time.Time) { } } +// syncInstance 只把连接的 ack / 慢标记同步到注册表,**不碰 lastHeartbeat**。 +// 心跳循环用它:服务端自己发过心跳不能算「对端还活着」的证据, +// 否则一块拔了网线的屏会一直被刷成「刚刚活跃」。 +func (h *Hub) syncInstance(c *client) { + c.mu.Lock() + id := c.instanceID + c.mu.Unlock() + if id == "" { + return + } + + h.mu.Lock() + defer h.mu.Unlock() + if st := h.instances[id]; st != nil { + st.ackVersion = c.ackVersion.Load() + st.slow = c.slow.Load() + } +} + // setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。 // targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后 // 每块屏的「当前页」必须各算各的。 +// +// targets 在这里再 TrimSpace 一次是防御性的:调用方(SendCommandToInstances)已经 +// 归一化过,但只要有第二个调用方忘了,注册表里就会出现「id 带空格」这类永远匹配不上的 +// 死条目,而症状是「指令发出去了、界面上的当前页不变」——很难查。 func (h *Hub) setInstancePage(page string, targets []string) { if page == "" { return @@ -140,7 +166,9 @@ func (h *Hub) setInstancePage(page string, targets []string) { if len(targets) > 0 { picked = make(map[string]bool, len(targets)) for _, id := range targets { - picked[id] = true + if id = strings.TrimSpace(id); id != "" { + picked[id] = true + } } } @@ -184,6 +212,11 @@ func (h *Hub) pruneInstancesLocked(now time.Time) { } // Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。 +// +// online = 「还有连接挂着」。这里刻意不叠加 TTL 判断:只要连接还在,客户端就是那条 +// 连接上的证据——而连接本身已经被 livenessLoop 用 ping/pong 求证过(不回 pong 的连接 +// 会先被断开、引用计数归零)。反过来,若在这里按 lastHeartbeat 再判一次, +// 一块正常但不发上行帧的大屏(数据没变时它只在收心跳)就会被误判成离线。 func (h *Hub) Instances() []Instance { h.mu.Lock() defer h.mu.Unlock() diff --git a/server/internal/realtime/protocol.go b/server/internal/realtime/protocol.go index cc40699..2a4d2c5 100644 --- a/server/internal/realtime/protocol.go +++ b/server/internal/realtime/protocol.go @@ -23,12 +23,19 @@ const ( ) // 房间名。admin: 由 AdminRoom 生成,用于定向通知。 +// +// admin: 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息): +// 保留它是因为 devdocs/05 §1 把「admin:(定向通知)」写成了协议的一部分, +// 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 C36。 const ( RoomDisplay = "display" RoomAdmin = "admin" ) // AdminRoom 返回某个管理端用户的定向房间名。 +// +// 只有带会话的管理端连接会加入这个房间(见 handleHello),身份来自会话而非客户端自报; +// 「guest 不能进管理端房间」这条鉴权语义由测试钉住,删房间会一起删掉那条覆盖。 func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) } // 连接角色。 diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go index bee58b3..7a75f89 100644 --- a/server/internal/realtime/snapshot.go +++ b/server/internal/realtime/snapshot.go @@ -215,7 +215,7 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { } // loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致: -// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、 +// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、奖牌开关生效、 // 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。 func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, values map[string]string) error { ledger, err := loadLedger(ctx, tx) diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go index 11ce0e5..37ac326 100644 --- a/server/internal/realtime/snapshot_test.go +++ b/server/internal/realtime/snapshot_test.go @@ -300,7 +300,7 @@ func TestSnapshotStandingsAppliesIndividualWeight(t *testing.T) { } } -// 团体奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。 +// 奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。 func TestSnapshotStandingsMedalSwitch(t *testing.T) { env := newTestEnv(t) db := env.db @@ -327,7 +327,7 @@ func TestSnapshotStandingsMedalSwitch(t *testing.T) { t.Fatalf("构建快照: %v", err) } if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 { - t.Fatalf("关闭团体奖牌后应清零: %+v", snap.Standings) + t.Fatalf("关闭奖牌开关后应清零: %+v", snap.Standings) } } diff --git a/server/internal/rules/medals_test.go b/server/internal/rules/medals_test.go index f7ff85f..09a9266 100644 --- a/server/internal/rules/medals_test.go +++ b/server/internal/rules/medals_test.go @@ -208,7 +208,7 @@ eventOverrides: t.Errorf("未写 awardMedals 的项目应保持缺省 1,实际 %d", got) } - // 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、团体奖牌并入照旧。 + // 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、并入单位榜照旧。 wants := map[string][3]int{ "EVT_IND": {1, 0, 0}, "EVT_TEAM": {1, 0, 1}, diff --git a/server/internal/rules/types.go b/server/internal/rules/types.go index b76fc6f..508fad7 100644 --- a/server/internal/rules/types.go +++ b/server/internal/rules/types.go @@ -63,7 +63,7 @@ type Standings struct { ShowIndividualMedals *bool `yaml:"showIndividualMedals,omitempty" json:"showIndividualMedals,omitempty"` } -// MedalCountsTeamEventsEnabled 缺省为 true(团体奖牌榜计入团体项目奖牌)。 +// MedalCountsTeamEventsEnabled 缺省为 true(单位奖牌榜计入团体项目奖牌)。 func (s Standings) MedalCountsTeamEventsEnabled() bool { return boolOrDefault(s.MedalCountsTeamEvents, true) } diff --git a/server/internal/store/migrate.go b/server/internal/store/migrate.go index b1682f8..e863b9e 100644 --- a/server/internal/store/migrate.go +++ b/server/internal/store/migrate.go @@ -2,15 +2,20 @@ package store import ( "database/sql" + "errors" "fmt" "io/fs" "log/slog" "sort" + "strings" "git.yeij.top/AskaEth/Scoreboard/server/migrations" ) -const createMigrationsTable = `CREATE TABLE IF NOT EXISTS schema_migrations ( +// 迁移记账用的表名,出现在报错里给现场照抄,所以单独提成常量。 +const migrationsTable = "schema_migrations" + +const createMigrationsTable = `CREATE TABLE IF NOT EXISTS ` + migrationsTable + ` ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )` @@ -21,7 +26,7 @@ func (s *Store) Migrate() error { if err != nil { return fmt.Errorf("定位系统库迁移目录: %w", err) } - return migrate(s.db, fsys, "system") + return migrate(s.db, fsys, "系统库") } // MigrateEvent 迁移赛事库(event.db)。赛事库由调用方自行打开, @@ -31,18 +36,20 @@ func MigrateEvent(db *sql.DB) error { if err != nil { return fmt.Errorf("定位赛事库迁移目录: %w", err) } - return migrate(db, fsys, "event") + return migrate(db, fsys, "赛事库") } // migrate 执行 fsys 中尚未应用的迁移,每个迁移在独立事务中完成。 +// label 是给人看的库名(「系统库」/「赛事库」),会出现在每一条报错里: +// 现场拿到一句「迁移失败」时,第一件要知道的就是**哪个库、哪个文件**。 func migrate(db *sql.DB, fsys fs.FS, label string) error { if _, err := db.Exec(createMigrationsTable); err != nil { - return fmt.Errorf("创建 schema_migrations: %w", err) + return fmt.Errorf("%s:创建 %s 表失败: %w", label, migrationsTable, err) } applied, err := appliedMigrations(db) if err != nil { - return err + return fmt.Errorf("%s:%w", label, err) } names, err := fs.Glob(fsys, "*.sql") @@ -57,16 +64,53 @@ func migrate(db *sql.DB, fsys fs.FS, label string) error { } body, err := fs.ReadFile(fsys, name) if err != nil { - return fmt.Errorf("读取迁移 %s: %w", name, err) + return fmt.Errorf("%s:读取迁移文件 %s 失败: %w", label, name, err) } if err := applyMigration(db, name, string(body)); err != nil { - return err + return migrateFailure(label, name, err) } slog.Info("已应用迁移", "db", label, "name", name) } return nil } +// migrateFailure 把一条迁移的失败翻译成现场能照着做的报错。 +// +// 为什么要专门做这件事:migrate 只按文件名记账(schema_migrations.name),没有校验和, +// 而历史迁移里有一批 `ALTER TABLE ... ADD COLUMN` 这种**不可重放**的语句 +// (0013/0014/0015/0018/0019/0020)。一旦 schema_migrations 少了一行 +// (手工删行、从别处拷库、把老库文件换回来),下次启动就会把已经执行过的迁移再跑一遍, +// 于是启动直接失败在「duplicate column name: ...」上:现场只看到一句英文 SQL 错误, +// 不知道该改哪儿,也不知道该不该动库。 +// +// 这里是**低成本护栏**,只做两件不需要动迁移机制的事: +// - 报错点名「哪个库 + 哪个文件」; +// - 失败长得像「重复执行」时,把成因和两种处置写清楚(补记一行 / 用备份恢复)。 +// +// 真正的修法(记账加校验和 + 迁移本身写成可重放)要改迁移机制,见审查清单 A08, +// 留待专门一轮。 +func migrateFailure(label, name string, err error) error { + msg := fmt.Sprintf("%s:迁移 %s 执行失败: %v", label, name, err) + if replayLike(err) { + msg += ";这条迁移很可能已经执行过,只是 " + migrationsTable + " 里没有它的记录" + + "(手工删过行 / 从别处拷来的库 / 换回了旧库文件)。确认库结构确实已经包含这次改动后," + + "补记一行再重启:" + fmt.Sprintf("INSERT INTO %s(name) VALUES('%s');", migrationsTable, name) + + "(若不确定,用备份恢复整库;不要修改已经跑过的迁移文件。)" + } + return errors.New(msg) +} + +// replayLike 判断这次失败是否像「把不可重放的迁移又执行了一遍」。 +// SQLite 的原文在这两种情况下是固定的:ALTER TABLE 重复加列报 duplicate column name, +// CREATE 重名报 already exists。只认这两句,别的失败(锁超时、SQL 写错、库损坏)照原样报。 +func replayLike(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "duplicate column name") || strings.Contains(s, "already exists") +} + func appliedMigrations(db *sql.DB) (map[string]bool, error) { rows, err := db.Query(`SELECT name FROM schema_migrations`) if err != nil { diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 422a517..ba2efb0 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -1,4 +1,6 @@ -// Package store 负责数据库连接、迁移与仓储。 +// Package store 只负责数据库连接(Open / Close / DB)与迁移(Migrate / MigrateEvent)。 +// +// 这里没有仓储代码:真正的读写都在 internal/httpapi 的各 repo*.go 里。 package store import ( diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index b25d9c8..23d79ee 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -2,9 +2,11 @@ package store import ( "database/sql" + "errors" "io/fs" "path/filepath" "sort" + "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/migrations" @@ -451,3 +453,49 @@ func queryString(t *testing.T, db *sql.DB, query string, args ...any) string { } return v } + +// migrate 只按文件名记账、没有校验和,而历史迁移里有一批 ADD COLUMN 是不可重放的。 +// schema_migrations 一旦少一行(手工删行 / 从别处拷库 / 换回老库文件),下次启动会把 +// 已经执行过的迁移再跑一遍并直接失败——这条用例守住低成本护栏:报错必须点名 +// 「哪个库 + 哪个文件」,并给出可照做的处置,而不是只丢一句英文 SQL 错误。 +func TestMigrateEventReportsReplayWithActionableError(t *testing.T) { + db := openEventDB(t) + if err := MigrateEvent(db); err != nil { + t.Fatalf("首次迁移: %v", err) + } + + // 0013 是 `ALTER TABLE athletes ADD COLUMN id_card`:重放必然失败。 + const lost = "0013_athletes_id_card.sql" + if _, err := db.Exec(`DELETE FROM schema_migrations WHERE name = ?`, lost); err != nil { + t.Fatalf("删掉迁移记录: %v", err) + } + + err := MigrateEvent(db) + if err == nil { + t.Fatal("记账丢行后重放不可重放的迁移必须报错,不能静默跳过") + } + msg := err.Error() + for _, want := range []string{"赛事库", lost, "已经执行过", "schema_migrations", "INSERT INTO"} { + if !strings.Contains(msg, want) { + t.Fatalf("报错应包含 %q(现场要照着报错直接处置),实际是:\n%s", want, msg) + } + } +} + +// 处置提示只跟着「重复执行」这一类失败走:别的失败(锁超时、SQL 写错、库损坏) +// 不能被误导成「补记一行就好」,那会把真故障盖掉。 +func TestMigrateFailureHintOnlyForReplay(t *testing.T) { + plain := migrateFailure("赛事库", "0099_demo.sql", errors.New("database is locked")) + if !strings.Contains(plain.Error(), "赛事库") || !strings.Contains(plain.Error(), "0099_demo.sql") { + t.Fatalf("报错必须点名库与文件: %s", plain) + } + if strings.Contains(plain.Error(), "已经执行过") { + t.Fatalf("非重放失败不该给出「补记一行」的提示: %s", plain) + } + + replay := migrateFailure("赛事库", "0013_athletes_id_card.sql", + errors.New("duplicate column name: id_card")) + if !strings.Contains(replay.Error(), "已经执行过") { + t.Fatalf("重放失败应给出处置提示: %s", replay) + } +} diff --git a/server/main.go b/server/main.go index 70b72ec..2a355ed 100644 --- a/server/main.go +++ b/server/main.go @@ -4,6 +4,7 @@ package main import ( "context" "database/sql" + "encoding/json" "errors" "flag" "fmt" @@ -83,6 +84,9 @@ func main() { if dataDirFromFlag { logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir) } + // 上一次恢复没走完就退出时留下的标记:必须在这里读一次,否则现场最需要线索的那次事故 + // 只能靠人去数据目录里翻一个没人提过的文件。 + warnPendingRestore(logger, cfg.DataDir) projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project) if err != nil { @@ -210,6 +214,45 @@ func initialAdminBanner(username, password string) string { "========================================\n\n", username, password) } +// warnPendingRestore 启动时读一次「恢复进行中」标记(数据目录根部的 pending_restore.json)。 +// +// 这个标记由「一键恢复」在替换库文件之前写下、正常收尾(无论成功还是失败)时立刻删掉, +// 所以它还留在数据目录里只有一种解释:上一次恢复没走到收尾(被强杀 / 掉电 / 蓝屏)。 +// 在那之前全仓没有任何一行代码读它——恢复出事后现场最需要的线索就静静躺在数据目录根部, +// 它既不在数据目录清单里,日志里也没有半点影子。这里把它变成启动时的一条 WARN。 +// +// 只读、不删:它是那一次未收尾恢复的唯一记录,删不删该由操作员决定(下一次成功的恢复会自己删掉它)。 +// 内容解析不了同样不拦启动:它只是线索,不是数据;库能不能打开由后面各步各自把关。 +func warnPendingRestore(logger *slog.Logger, dataDir string) { + if strings.TrimSpace(dataDir) == "" { + return + } + path := filepath.Join(dataDir, export.PendingRestoreFile) + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return + } + if err != nil { + logger.Warn("读取上次恢复的标记失败", "path", path, "err", err) + return + } + var pending export.PendingRestore + if err := json.Unmarshal(raw, &pending); err != nil { + logger.Warn("上次恢复的标记内容无法解析,请手工检查这个文件", "path", path, "err", err) + return + } + logger.Warn("检测到上一次「一键恢复」未收尾的标记:恢复中途进程退出了", + "path", path, + "kind", string(pending.Kind), + "backupName", pending.BackupName, + "target", pending.Target, + "safetyCopy", pending.SafetyCopy, + "operator", pending.Operator, + "requestedAt", pending.RequestedAt.Format(time.RFC3339), + "hint", "当前库可能停在替换后的状态;要重做就用备份再恢复一次,"+ + "要退回替换前的现场就用上面那份安全副本恢复") +} + // displaySender 把 httpapi 的大屏命令接口适配到实时层。 type displaySender struct{ hub *realtime.Hub } diff --git a/server/main_test.go b/server/main_test.go index 968daf4..68d2ee1 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -1,11 +1,15 @@ package main import ( + "bytes" + "log/slog" "os" "path/filepath" "strings" "testing" + "time" + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) @@ -253,3 +257,99 @@ func TestInitialAdminBannerAsciiAnchor(t *testing.T) { t.Fatalf("ASCII 锚应在密码行之后:\n%s", banner) } } + +// ---------- C35:恢复标记的启动告警 ---------- + +// writeRestoreMarker 用真实写入路径(export.WritePendingRestore)造一份恢复标记, +// 字段名与落盘格式都跟「一键恢复」写下的那份一模一样。 +func writeRestoreMarker(t *testing.T, dataDir string, p export.PendingRestore) string { + t.Helper() + path, err := export.WritePendingRestore(dataDir, p) + if err != nil { + t.Fatalf("写恢复标记: %v", err) + } + return path +} + +// sampleRestoreMarker 是一份典型的「恢复写到一半进程没了」的标记。 +func sampleRestoreMarker() export.PendingRestore { + return export.PendingRestore{ + Kind: export.RestoreKindEvent, + Backup: "/data/backups/event-manual-20260911-201500.db", + BackupName: "event-manual-20260911-201500.db", + Target: "/data/event_projs/autumn/event.db", + SafetyCopy: "/data/backups/pre-restore-event-20260911-201503.db", + Operator: "admin#1", + RequestedAt: time.Date(2026, 9, 11, 20, 15, 3, 0, time.UTC), + } +} + +// 恢复标记留在数据目录里 = 上一次恢复没走完。启动时必须读到它并告警, +// 且告警要带上「盖的是哪个库、用哪份备份、退回用的安全副本是哪份、谁点的」—— +// 只说一句「有残留文件」,现场还是不知道该怎么办。 +func TestWarnPendingRestoreReportsMarker(t *testing.T) { + dataDir := t.TempDir() + marker := writeRestoreMarker(t, dataDir, sampleRestoreMarker()) + + buf := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(buf, nil)) + warnPendingRestore(logger, dataDir) + + logs := buf.String() + for _, want := range []string{ + `"level":"WARN"`, + "未收尾", + sampleRestoreMarker().BackupName, + sampleRestoreMarker().Target, + sampleRestoreMarker().SafetyCopy, + sampleRestoreMarker().Operator, + "2026-09-11T20:15:03Z", + } { + if !strings.Contains(logs, want) { + t.Fatalf("启动告警应含 %q,得到:\n%s", want, logs) + } + } + // 只告警、不删:这份标记是那次未收尾恢复的唯一记录,删不删该由操作员决定。 + if _, err := os.Stat(marker); err != nil { + t.Fatalf("告警不该动这份标记(它是唯一线索): %v", err) + } +} + +// 没有标记时一个字都不许刷:正常启动的日志里出现「未收尾」会把人引向不存在的事故。 +func TestWarnPendingRestoreSilentWithoutMarker(t *testing.T) { + buf := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(buf, nil)) + warnPendingRestore(logger, t.TempDir()) + if buf.Len() != 0 { + t.Fatalf("没有恢复标记时不该产生任何日志,得到:\n%s", buf.String()) + } +} + +// 标记内容坏了(写了一半 / 被人手工改坏)不能拦启动、也不能静默:要告警说明这个文件有问题。 +func TestWarnPendingRestoreToleratesBrokenMarker(t *testing.T) { + dataDir := t.TempDir() + path := filepath.Join(dataDir, export.PendingRestoreFile) + if err := os.WriteFile(path, []byte("{不是 JSON"), 0o644); err != nil { + t.Fatalf("写坏标记: %v", err) + } + + buf := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(buf, nil)) + warnPendingRestore(logger, dataDir) + + if !strings.Contains(buf.String(), "无法解析") || !strings.Contains(buf.String(), path) { + t.Fatalf("坏标记应告警并指出文件路径,得到:\n%s", buf.String()) + } +} + +// 光测上面那几个函数还不够:漏掉 main 里那一行调用,标记照样没人读(这正是 C35 的原状—— +// 写入方齐全、读取方为零)。这里连同调用点一起钉住,参考网页侧「模板契约断言」的做法。 +func TestMainReadsPendingRestoreMarkerAtStartup(t *testing.T) { + raw, err := os.ReadFile("main.go") + if err != nil { + t.Fatalf("读 main.go: %v", err) + } + if !strings.Contains(string(raw), "warnPendingRestore(logger, cfg.DataDir)") { + t.Fatalf("main.go 里找不到启动时读恢复标记的调用:标记又会变回没人读的孤儿文件") + } +} diff --git a/server/migrations/event/0008_result_revisions.sql b/server/migrations/event/0008_result_revisions.sql index 4a76796..db365e4 100644 --- a/server/migrations/event/0008_result_revisions.sql +++ b/server/migrations/event/0008_result_revisions.sql @@ -1,4 +1,6 @@ --- 成绩版本图谱:parent_id 形成 DAG,撤销后从撤销点分支,仅用于可视化。 +-- 成绩版本图谱:parent_id 指向上一条版本,仅用于可视化。 +-- ⚠️ 不是 DAG:写入侧(httpapi.recordRevision)恒取「同小项当前 MAX(id)」,所以一个小项一条链、 +-- 撤销后再发布也接在链尾,不会从撤销点分出分支;每个小项的第一条版本 parent_id 为 NULL。 CREATE TABLE IF NOT EXISTS result_revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES result_revisions(id), diff --git a/server/migrations/event/migration_legacy_test.go b/server/migrations/event/migration_legacy_test.go new file mode 100644 index 0000000..52f974b --- /dev/null +++ b/server/migrations/event/migration_legacy_test.go @@ -0,0 +1,291 @@ +package event + +import ( + "database/sql" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// C12:0001–0011 的老库一次性升到最新。 +// +// 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳; +// 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0020 九条, +// 其中 0016 会就地改写 events 的建表语句、0017 会重建 score_ledger——这些步骤正是 +// 「只在老库上才走得到」的路径。这条用例把这条路整条走一遍: +// 数据一行不少、新列取文档写明的默认值、新表建出来、升级后新能力可用、再跑一次也不报错。 +func TestMigration0001ToLatestKeepsData(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0012") // 只应用 0001–0011,回到「很久没升级过」的现场 + + // 老库数据:只用 0011 之前存在的列(id_card / bib / age / deleted_at / heat / avg_mode 等都还不存在)。 + stmts := []string{ + `INSERT INTO teams(code, name, short_name, color, sort_order, active) + VALUES ('T1', '第一分公司', '一司', '#FF0000', 3, 0)`, + `INSERT INTO athletes(name, gender, team_id, employee_no, active) VALUES ('张三', '男', 1, 'E001', 1)`, + `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 5, 1)`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, sort_order, venue, remark) + VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'published', 7, '田径场', '备注')`, + `INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order) + VALUES (1, '男子', 3, '男', 18, 35, 9)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) VALUES (1, 'athlete', 'result', 1, 0)`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, is_tie, status, state) + VALUES (1, 1, 1, '11.58', 11.58, 0, 'ok', 'published')`, + `INSERT INTO records(event_id, level, result_value, holder_name, is_current, source) + VALUES (1, 'meet', 11.2, '李四', 1, 'history')`, + `INSERT INTO record_breaks(event_id, entry_id, level, result_value, bonus_points, state) + VALUES (1, 1, 'meet', 11.2, 5, 'approved')`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`, + `INSERT INTO score_ledger(team_id, source_type, source_id, points, voided, void_reason) + VALUES (1, 'adjustment', 1, -3, 1, '人工扣分')`, + `INSERT INTO point_adjustments(team_id, delta, category, reason, event_id, voided) + VALUES (1, 5, 'bonus', '测试奖励', 1, 0)`, + `INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json) + VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`, + `INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES ('team', 1, 'upsert', '{}')`, + `INSERT INTO audit_logs(user_id, action, entity_type, entity_id, ip, request_id) + VALUES (1, 'team.create', 'team', 1, '127.0.0.1', 'req-1')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造老库数据 %q: %v", s, err) + } + } + + // 升级前:新列与新表都不该在。 + for _, col := range []string{"id_card", "bib", "age"} { + var probe sql.NullString + if err := db.QueryRow(`SELECT ` + col + ` FROM athletes`).Scan(&probe); err == nil { + t.Fatalf("升级前不该有 athletes.%s 列", col) + } + } + for _, table := range []string{"channel_ranks", "channel_manual_ranks"} { + 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.Fatalf("升级前不该有 %s 表", table) + } + } + + // 走真实升级路径:一次补跑 0012~0020 全部九条。 + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("从 0001–0011 直接升到最新失败: %v", err) + } + if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { + t.Errorf("迁移登记数 = %d,期望 %d", got, want) + } + + // ① 数据一行不少、一行不多(迁移里没有隐式的删改)。 + for table, want := range map[string]int{ + "teams": 1, "athletes": 1, "sports": 1, "events": 1, "event_categories": 1, + "event_score_channels": 1, "entries": 1, "results": 1, "records": 1, "record_breaks": 1, + "score_ledger": 2, "point_adjustments": 1, "result_revisions": 2, "change_log": 1, "audit_logs": 1, + } { + if got := countRows(t, db, table); got != want { + t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want) + } + } + // 项目里的积分模板由 0011 种下,升级不能把它冲掉(新库现场靠它算分)。 + if got := countRows(t, db, "scoring_profiles"); got != 2 { + t.Errorf("scoring_profiles 行数 = %d,期望 2(0011 种下的两个模板)", got) + } + if got := countRows(t, db, "scoring_profile_rules"); got != 12 { + t.Errorf("scoring_profile_rules 行数 = %d,期望 12", got) + } + + // ② 老行上的关键取值原样保留。 + var ( + teamName, teamColor string + teamActive, teamSortOrder int + ) + if err := db.QueryRow(`SELECT name, COALESCE(color, ''), active, sort_order FROM teams WHERE id = 1`). + Scan(&teamName, &teamColor, &teamActive, &teamSortOrder); err != nil { + t.Fatalf("读单位: %v", err) + } + if teamName != "第一分公司" || teamColor != "#FF0000" || teamActive != 0 || teamSortOrder != 3 { + t.Errorf("老单位被改动: name=%q color=%q active=%d sort=%d", teamName, teamColor, teamActive, teamSortOrder) + } + var ( + eventName, eventStatus, venue, remark string + eventMultiplier float64 + eventPublished string + ) + if err := db.QueryRow(`SELECT name, status, COALESCE(venue, ''), COALESCE(remark, ''), multiplier + FROM events WHERE id = 1`). + Scan(&eventName, &eventStatus, &venue, &remark, &eventMultiplier); err != nil { + t.Fatalf("读小项: %v", err) + } + eventPublished = eventStatus + if eventName != "100米跑" || eventPublished != "published" || venue != "田径场" || + remark != "备注" || eventMultiplier != 2 { + t.Errorf("老小项被改动: name=%q status=%q venue=%q remark=%q multiplier=%v", + eventName, eventPublished, venue, remark, eventMultiplier) + } + var rank, resultState string + var resultValue float64 + if err := db.QueryRow(`SELECT rank, state, result_value FROM results WHERE entry_id = 1`). + Scan(&rank, &resultState, &resultValue); err != nil { + t.Fatalf("读成绩: %v", err) + } + if rank != "1" || resultState != "published" || resultValue != 11.58 { + t.Errorf("老成绩被改动: rank=%s state=%q value=%v", rank, resultState, resultValue) + } + var ledgerPoints float64 + var ledgerVoided int + if err := db.QueryRow(`SELECT points, voided FROM score_ledger WHERE source_type = 'adjustment'`). + Scan(&ledgerPoints, &ledgerVoided); err != nil { + t.Fatalf("读流水: %v", err) + } + if ledgerPoints != -3 || ledgerVoided != 1 { + t.Errorf("老流水被改动: points=%v voided=%d", ledgerPoints, ledgerVoided) + } + // 重建 score_ledger(0017)之后父子关系与自增序列都要正常。 + var childParent sql.NullInt64 + if err := db.QueryRow(`SELECT parent_id FROM result_revisions WHERE id = 2`).Scan(&childParent); err != nil { + t.Fatalf("读变更历史: %v", err) + } + if !childParent.Valid || childParent.Int64 != 1 { + t.Errorf("result_revisions 父子关系未保留: %+v", childParent) + } + + // ③ 每个新列/新表都按文档取默认值:0013–0019 是可空列(老行必须为空), + // 0017 与 0020 的开关默认 1(与升级前「算分、算奖牌、计入总分」的行为一致)。 + var ( + idCard, bib sql.NullString + age sql.NullInt64 + heat sql.NullInt64 + ) + if err := db.QueryRow(`SELECT id_card, bib, age FROM athletes WHERE id = 1`). + Scan(&idCard, &bib, &age); err != nil { + t.Fatalf("读人员新列: %v", err) + } + if idCard.Valid || bib.Valid || age.Valid { + t.Errorf("老人员的新列应全为空: idCard=%v bib=%v age=%v", idCard, bib, age) + } + var deletedAt sql.NullString + if err := db.QueryRow(`SELECT deleted_at FROM teams WHERE id = 1`).Scan(&deletedAt); err != nil { + t.Fatalf("读 deleted_at: %v", err) + } + if deletedAt.Valid { + t.Errorf("老单位的 deleted_at 应为空: %q", deletedAt.String) + } + if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&heat); err != nil { + t.Fatalf("读 heat: %v", err) + } + if heat.Valid { + t.Errorf("老报名的 heat 应为空(未分批): %d", heat.Int64) + } + var ( + avgMode string + eventAwardMedals, scored int + countToTeam int + ) + if err := db.QueryRow(`SELECT avg_mode, award_medals, scored FROM events WHERE id = 1`). + Scan(&avgMode, &eventAwardMedals, &scored); err != nil { + t.Fatalf("读小项新列: %v", err) + } + if avgMode != "valid" || eventAwardMedals != 1 || scored != 1 { + t.Errorf("老小项的新开关不对: avg_mode=%q award_medals=%d scored=%d", avgMode, eventAwardMedals, scored) + } + if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil { + t.Fatalf("读通道: %v", err) + } + if countToTeam != 1 { + t.Errorf("老通道 count_to_team = %d,期望 1", countToTeam) + } + var ledgerCountToTeam int + if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE source_type = 'result'`).Scan(&ledgerCountToTeam); err != nil { + t.Fatalf("读老流水: %v", err) + } + if ledgerCountToTeam != 1 { + t.Errorf("重建后的老流水 count_to_team = %d,期望 1", ledgerCountToTeam) + } + var countToMedals, countToTeamMedals int + if err := db.QueryRow(`SELECT count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = 1`). + Scan(&countToMedals, &countToTeamMedals); err != nil { + t.Fatalf("读通道奖牌开关: %v", err) + } + if countToMedals != 1 || countToTeamMedals != 1 { + t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals) + } + + // ④ 新表建出来了,且是空的(升级不该凭空造名次)。 + for _, table := range []string{"channel_ranks", "channel_manual_ranks"} { + if got := countRows(t, db, table); got != 0 { + t.Errorf("%s 升级后应为空表,得到 %d 行", table, got) + } + } + // 0012 补的 (entry_id, voided) 索引与 0013–0015 的唯一索引都必须建在。 + var indexCount int + if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN + ('idx_score_ledger_entry_voided', 'idx_athletes_id_card', + 'idx_athletes_bib', 'idx_channel_ranks_unique')`).Scan(&indexCount); err != nil { + t.Fatalf("查索引: %v", err) + } + if indexCount != 4 { + t.Errorf("升级后索引缺失,命中 %d / 4", indexCount) + } + + // ⑤ 外键与数据一致性没有悬挂(0016 改的是建表语句、0017 重建过表)。 + fkRows, err := db.Query(`PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("外键检查: %v", err) + } + defer func() { _ = fkRows.Close() }() + if fkRows.Next() { + t.Error("跨九条迁移升级后存在外键悬挂") + } + var fkEnabled int + if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fkEnabled); err != nil { + t.Fatalf("读外键开关: %v", err) + } + if fkEnabled != 1 { + t.Error("升级后外键开关应仍然打开") + } + + // ⑥ 升级后的库要能真的用上这批新能力(列建了但写不进去,等于没升)。 + if _, err := db.Exec(`UPDATE athletes SET id_card = '110101199001011234', bib = 'A001', age = 30 WHERE id = 1`); err != nil { + t.Fatalf("写人员新列: %v", err) + } + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode, award_medals) + VALUES (1, 'E2', '双计项', 'individual_team', 'all', 0)`); err != nil { + t.Fatalf("写 0016/0017/0020 的新枚举与新开关: %v", err) + } + if _, err := db.Exec(`UPDATE entries SET heat = 2 WHERE id = 1`); err != nil { + t.Fatalf("写 heat: %v", err) + } + if _, err := db.Exec(`UPDATE teams SET deleted_at = datetime('now') WHERE id = 1`); err != nil { + t.Fatalf("写 deleted_at: %v", err) + } + if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, + subject_key, entry_id, result_id, rank, points) + VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil { + t.Fatalf("写 0020 新建的名次表: %v", err) + } + // 新 CHECK 也在:合法的拒绝了非法值才算真的生效。 + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil { + t.Error("升级后非法 event_type 应被 CHECK 拒绝") + } + if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil { + t.Error("升级后非法 avg_mode 应被 CHECK 拒绝") + } + + // ⑦ 再跑一次迁移:不报错,也不再改动任何数据。 + countsBefore := map[string]int{} + for _, table := range []string{"teams", "athletes", "events", "score_ledger", "channel_ranks", "schema_migrations"} { + countsBefore[table] = countRows(t, db, table) + } + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("重复迁移: %v", err) + } + for table, before := range countsBefore { + if got := countRows(t, db, table); got != before { + t.Errorf("重复迁移改动 %s 行数:%d → %d", table, before, got) + } + } +} diff --git a/web/scripts/check-declaration-order.d.mts b/web/scripts/check-declaration-order.d.mts new file mode 100644 index 0000000..885b813 --- /dev/null +++ b/web/scripts/check-declaration-order.d.mts @@ -0,0 +1,23 @@ +/** `check-declaration-order.mjs` 的类型声明:脚本本身是 .mjs(构建期工具), + * 这里只声明被测试引用的那几个导出,让 vue-tsc 能对测试文件做类型检查。 */ + +export interface OrderViolation { + /** 违规所在的文件(相对扫描根目录)。 */ + file: string + /** 发生「注册期求值」的行号(1 基)。 */ + line: number + /** 会被求值到的 const 名。 */ + name: string + /** 该 const 的声明行号(1 基),一定大于 line。 */ + declaredAt: number +} + +export function scriptBlocksOf(fileName: string, source: string): Array<{ offsetLine: number; code: string }> +export function constDeclarations( + code: string, + offsetLine?: number, +): Map }> +export function eagerWatchRefs(code: string, offsetLine?: number): Array<{ expr: string; line: number }> +export function findOrderViolations(fileName: string, source: string): OrderViolation[] +export function sourceFilesUnder(root: string): string[] +export function scanWebSources(root: string): OrderViolation[] diff --git a/web/src/admin/components/ConfirmDialog.vue b/web/src/admin/components/ConfirmDialog.vue index 36f455b..5eb3d56 100644 --- a/web/src/admin/components/ConfirmDialog.vue +++ b/web/src/admin/components/ConfirmDialog.vue @@ -28,9 +28,21 @@ const props = withDefaults( const emit = defineEmits<{ confirm: []; cancel: [] }>() +/** + * 关闭弹窗的唯一出口:ESC、遮罩、右上角关闭键都走这里。 + * + * pending 表示不可逆操作(恢复整库 / 彻底删除 / 重算榜单)正在服务端执行, + * 这时必须**完全锁死**——只禁用底部按钮不够:ESC 或点遮罩关掉弹窗后, + * 操作员会以为「我取消了」,而服务端其实已经做完,且再也没有回执可看。 + */ +function requestCancel(): void { + if (props.pending) return + emit('cancel') +} + function onKeydown(event: KeyboardEvent): void { if (!props.open) return - if (event.key === 'Escape') emit('cancel') + if (event.key === 'Escape') requestCancel() } onMounted(() => window.addEventListener('keydown', onKeydown)) @@ -38,11 +50,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) + +
+
+ 有 {{ conflicts.length }} 行没有加入,需要逐条确认 + + 这些行命中了库里已有人员,但姓名 / 证件号 / 性别 / 年龄 / 单位 / 号码布与库里的对不上, + 本轮既没有建报名也没有报错。到本页「报名导入」分区的冲突面板逐条选择保留哪一侧 + (POST /api/entries/import/resolve),决策后这些行才会写入。 + +
+
    +
  • + 第 {{ conflict.row }} 行 · {{ conflictLabel(conflict) }} · {{ conflict.existing.name }} + +
  • +
+
+
{{ error }}
@@ -637,4 +674,28 @@ async function submitTeam(): Promise { padding: 10px 12px; line-height: 1.5; } +/* 冲突明细的样式与 ImportSection.vue 的冲突 / 错误块逐条对齐,两处看起来是同一套东西。 */ +.errors { + margin-top: 12px; +} +.errors-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} +.error-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 260px; + overflow-y: auto; + padding: 10px 12px; + border-radius: var(--mx-r-sm); + background: var(--mx-s2); + font-size: 12px; + color: var(--mx-t2); + font-family: var(--mx-mono); +} diff --git a/web/src/admin/components/baseData/ImportSection.vue b/web/src/admin/components/baseData/ImportSection.vue index 5840fb3..88e3928 100644 --- a/web/src/admin/components/baseData/ImportSection.vue +++ b/web/src/admin/components/baseData/ImportSection.vue @@ -4,6 +4,7 @@ import type { ConflictAction, EntryImportResult, ImportConflict, SameNameNewPers import MxIcon from '../MxIcon.vue' import EntryPicker from './EntryPicker.vue' import { useBaseData } from './useBaseData' +import { entryImportSizeError } from './importLimits' import { useToastStore } from '../../stores/toast' import { REQUIRED_COLUMNS, @@ -28,8 +29,6 @@ const TEMPLATE_CSV = [ 'BB,混合组,F2,,,,,,种子1,,registered,第一分公司代表队', ].join('\n') -const MAX_SIZE = 16 * 1024 * 1024 - const fileInput = ref(null) const picked = ref(null) const dragging = ref(false) @@ -68,8 +67,9 @@ function acceptFile(file: File): void { error.value = '只支持 .csv 或 .xlsx 报名表' return } - if (file.size > MAX_SIZE) { - error.value = '文件超过 16MB,服务端会拒绝解析' + const tooBig = entryImportSizeError(file.size) + if (tooBig !== null) { + error.value = tooBig return } picked.value = file @@ -223,7 +223,7 @@ function downloadTemplate(): void {
点击选择,或把 CSV / XLSX 拖到这里
- 单个文件不超过 16MB;xlsx 由服务端解析第一个工作表;CSV 直接用 Excel「另存为 CSV」即可(UTF-8、带 BOM 的 UTF-8、GBK/GB18030、UTF-16 都能读,不用特意转码) + 单个文件不超过 32MB;xlsx 由服务端解析第一个工作表;CSV 直接用 Excel「另存为 CSV」即可(UTF-8、带 BOM 的 UTF-8、GBK/GB18030、UTF-16 都能读,不用特意转码)
diff --git a/web/src/admin/components/baseData/ProfilesSection.vue b/web/src/admin/components/baseData/ProfilesSection.vue index 7966389..5c1fcaa 100644 --- a/web/src/admin/components/baseData/ProfilesSection.vue +++ b/web/src/admin/components/baseData/ProfilesSection.vue @@ -484,7 +484,7 @@ const totalTop = computed(() => {
+ + + 改动未保存 +