fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。
## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。
主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试
## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。
## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败
## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
1 parent
db94d9c119
commit
6841cf49aa
147 files changed
+8141
-423
No files matched your search
+8
-5
@@ -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=<id>&instance=<host>-<id>`,页序按 `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:<port>/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` 指向它)。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
+11
-6
@@ -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
|
||||
|
||||
+100
-29
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scoreboard-console",
|
||||
"productName": "Scoreboard Console",
|
||||
"version": "0.1.9",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"author": "AskaEth",
|
||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||
|
||||
+31
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+21
-6
@@ -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
|
||||
|
||||
+3
-3
@@ -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 自动拉起
|
||||
|
||||
|
||||
+26
-21
@@ -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 |
|
||||
| 实时赛况 | 当前正在进行项目的实时排名(见下方说明) |
|
||||
|
||||
+56
-1
@@ -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
|
||||
|
||||
### 一句话
|
||||
|
||||
|
||||
+11
-11
@@ -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)。
|
||||
|
||||
|
||||
+1
-1
@@ -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 只读第一个工作表 | 把要导入的数据放在第一个工作表里 |
|
||||
|
||||
---
|
||||
|
||||
+9
-6
@@ -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>` 已移除,任何情况下都不再显示(要核对某块屏是否跟上服务端,看管理端仪表盘「实时连接」里的「版本 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)
|
||||
- [ ] 管理端各角色账号可登录,权限正确
|
||||
|
||||
+159
-36
@@ -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) == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,请改用人工积分调整")
|
||||
)
|
||||
@@ -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。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ type StandingsRow struct {
|
||||
Medals int `json:"medals"`
|
||||
}
|
||||
|
||||
// MedalRow 团体奖牌榜的一行。
|
||||
// MedalRow 单位奖牌榜的一行。
|
||||
type MedalRow struct {
|
||||
Rank int `json:"rank"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: 导出路径为空")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ""})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 验证个人分折算系数确实来自赛事库设置。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{"第一分公司积分明细"}},
|
||||
|
||||
@@ -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 长得一模一样。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
|
||||
"请求被拦截",
|
||||
"无权访问该资源",
|
||||
"请先登录",
|
||||
"CSRF 校验失败,请刷新页面重试",
|
||||
"CSRF 校验失败,请退出后重新登录",
|
||||
"请先修改初始密码",
|
||||
"internal/middleware/middleware.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 次应被限流——库里的登录限流设置没有被读到")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(空表)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("空请求体应报错")
|
||||
}
|
||||
}
|
||||
@@ -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 是「请求体里真的出现过哪些键」。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 不该是空值")
|
||||
}
|
||||
}
|
||||
@@ -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 对已登录用户的写操作限流。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -23,12 +23,19 @@ const (
|
||||
)
|
||||
|
||||
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
|
||||
//
|
||||
// admin:<userId> 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息):
|
||||
// 保留它是因为 devdocs/05 §1 把「admin:<userId>(定向通知)」写成了协议的一部分,
|
||||
// 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 C36。
|
||||
const (
|
||||
RoomDisplay = "display"
|
||||
RoomAdmin = "admin"
|
||||
)
|
||||
|
||||
// AdminRoom 返回某个管理端用户的定向房间名。
|
||||
//
|
||||
// 只有带会话的管理端连接会加入这个房间(见 handleHello),身份来自会话而非客户端自报;
|
||||
// 「guest 不能进管理端房间」这条鉴权语义由测试钉住,删房间会一起删掉那条覆盖。
|
||||
func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) }
|
||||
|
||||
// 连接角色。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Package store 负责数据库连接、迁移与仓储。
|
||||
// Package store 只负责数据库连接(Open / Close / DB)与迁移(Migrate / MigrateEvent)。
|
||||
//
|
||||
// 这里没有仓储代码:真正的读写都在 internal/httpapi 的各 repo*.go 里。
|
||||
package store
|
||||
|
||||
import (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 里找不到启动时读恢复标记的调用:标记又会变回没人读的孤儿文件")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user