fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)

## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

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