## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { ApiError } from '@/shared'
|
||
import { ENTRY_BATCH_PATH, submitEntryBatch } from '../pages/entryBatch'
|
||
|
||
/**
|
||
* 报名项批量操作的请求契约:方法 / 路径 / 请求体字段必须与服务端
|
||
* internal/httpapi/entries_batch.go 的 entryBatchPayload 一致;
|
||
* 写请求漏掉 X-CSRF-Token 会被中间件 403,现场只会看到「删不掉」。
|
||
*/
|
||
|
||
interface Captured {
|
||
url?: string
|
||
method?: string
|
||
headers?: Record<string, string>
|
||
body?: unknown
|
||
credentials?: string
|
||
}
|
||
|
||
let lastRequest: Captured = {}
|
||
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })
|
||
|
||
function jsonResponse(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
})
|
||
}
|
||
|
||
function emptySummary() {
|
||
return {
|
||
ok: 0,
|
||
failed: 0,
|
||
skipped: 0,
|
||
changed: 0,
|
||
removedEntries: 0,
|
||
removedResults: 0,
|
||
removedLedger: 0,
|
||
removedBreaks: 0,
|
||
recomputedEvents: [],
|
||
}
|
||
}
|
||
|
||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||
lastRequest = {
|
||
url: String(input),
|
||
method: init?.method ?? 'GET',
|
||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||
credentials: init?.credentials,
|
||
}
|
||
return reply()
|
||
}) as typeof fetch
|
||
|
||
function respondWith(response: () => Response): void {
|
||
lastRequest = {}
|
||
reply = response
|
||
}
|
||
|
||
test('批量操作走 POST /api/entries/batch,body 带 action + ids', async () => {
|
||
respondWith(() =>
|
||
jsonResponse({
|
||
ok: true,
|
||
data: {
|
||
items: [{ id: 1, ok: true }],
|
||
summary: { ...emptySummary(), ok: 1, changed: 1, recomputedEvents: [3] },
|
||
},
|
||
}),
|
||
)
|
||
|
||
const result = await submitEntryBatch({ action: 'scratch', ids: [1, 1, 2] })
|
||
|
||
assert.equal(lastRequest.url, ENTRY_BATCH_PATH)
|
||
assert.equal(lastRequest.method, 'POST')
|
||
assert.equal(lastRequest.credentials, 'include')
|
||
assert.equal((lastRequest.body as { action: string }).action, 'scratch')
|
||
assert.deepEqual((lastRequest.body as { ids: number[] }).ids, [1, 1, 2])
|
||
// 写请求必须有 requestId(幂等)与 JSON 内容类型。
|
||
assert.equal(typeof (lastRequest.body as { requestId?: unknown }).requestId, 'string')
|
||
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
|
||
assert.deepEqual(result.summary.recomputedEvents, [3])
|
||
})
|
||
|
||
// 已登录写请求必须带 CSRF:漏掉就是 403,退赛 / 移出都不会生效。
|
||
test('带 csrf_token Cookie 时补上 X-CSRF-Token', async () => {
|
||
const original = (globalThis as { document?: unknown }).document
|
||
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-123; b=2' }
|
||
try {
|
||
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||
await submitEntryBatch({ action: 'restore', ids: [2] })
|
||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-123')
|
||
} finally {
|
||
if (original === undefined) delete (globalThis as { document?: unknown }).document
|
||
else (globalThis as { document?: unknown }).document = original
|
||
}
|
||
})
|
||
|
||
// 服务端是两层幂等:外层 cached 认 X-Request-Id,内层 idempotent 认 body.requestId。
|
||
// 两处各生成一个 id,服务端永远拼不出同一个 key —— 超时重试「移出报名」会真的删第二遍。
|
||
test('X-Request-Id 与 body.requestId 是同一次调用的同一个值', async () => {
|
||
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||
|
||
await submitEntryBatch({ action: 'remove', ids: [7] })
|
||
|
||
const headerId = lastRequest.headers?.['X-Request-Id']
|
||
const bodyId = (lastRequest.body as { requestId?: unknown }).requestId
|
||
assert.equal(typeof headerId, 'string')
|
||
assert.equal(bodyId, headerId, 'header 与 body 必须是同一个幂等键,否则服务端两层缓存都命中不了')
|
||
})
|
||
|
||
test('调用方显式给的 requestId 会同时用于 header 与 body', async () => {
|
||
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||
|
||
await submitEntryBatch({ action: 'scratch', ids: [8], requestId: 'retry-1' })
|
||
|
||
assert.equal(lastRequest.headers?.['X-Request-Id'], 'retry-1')
|
||
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, 'retry-1')
|
||
})
|
||
|
||
test('没有 document(非浏览器环境)时不带 CSRF,也不报错', async () => {
|
||
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||
await submitEntryBatch({ action: 'remove', ids: [3] })
|
||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], undefined)
|
||
})
|
||
|
||
// 服务端的中文原文要原样传给页面;批量里的逐条失败也在 data 里,不能被当成请求失败。
|
||
test('服务端报错时原样抛出 code 与中文 message', async () => {
|
||
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '一次最多处理 500 条报名项,请分批提交' } }, 400))
|
||
await assert.rejects(
|
||
() => submitEntryBatch({ action: 'remove', ids: [1] }),
|
||
(error: unknown) => {
|
||
assert.ok(error instanceof ApiError)
|
||
assert.equal(error.code, 'VALIDATION_FAILED')
|
||
assert.match(error.message, /分批提交/)
|
||
return true
|
||
},
|
||
)
|
||
})
|
||
|
||
test('逐条失败(ok:false)仍算成功响应,由页面逐条提示', async () => {
|
||
respondWith(() =>
|
||
jsonResponse({
|
||
ok: true,
|
||
data: {
|
||
items: [
|
||
{ id: 1, ok: true },
|
||
{ id: 999999, ok: false, error: '报名项不存在' },
|
||
],
|
||
summary: { ...emptySummary(), ok: 1, failed: 1 },
|
||
},
|
||
}),
|
||
)
|
||
const result = await submitEntryBatch({ action: 'remove', ids: [1, 999999] })
|
||
assert.equal(result.summary.failed, 1)
|
||
assert.equal(result.items[1]?.error, '报名项不存在')
|
||
})
|
||
|
||
test('网络不可达时报「没有生效」,不能让操作员以为已经删掉了', async () => {
|
||
respondWith(() => {
|
||
throw new TypeError('fetch failed')
|
||
})
|
||
await assert.rejects(
|
||
() => submitEntryBatch({ action: 'remove', ids: [1] }),
|
||
(error: unknown) => {
|
||
assert.ok(error instanceof ApiError)
|
||
assert.equal(error.code, 'NETWORK')
|
||
assert.match(error.message, /没有生效/)
|
||
return true
|
||
},
|
||
)
|
||
})
|