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

+5 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useScoreboard } from '../useScoreboard'
import { DEFAULT_ROTATE_SECONDS } from '../guards'
type TickerKind = 'medals' | 'results' | 'announcement'
@@ -11,7 +12,7 @@ interface TickerItem {
const { standings, latestResults, announceText, detail, settings } = useScoreboard()
// 与团体奖牌榜页同一口径:按服务端名次(总分优先)取前 3,不在前端按金→银→铜重排。
// 与单位奖牌榜页同一口径:按服务端名次(总分优先)取前 3,不在前端按金→银→铜重排。
const topMedals = computed(() => standings.value.slice(0, 3))
const latestGroup = computed(() => latestResults.value[0] ?? null)
@@ -28,7 +29,9 @@ const items = computed<TickerItem[]>(() => {
const index = ref(0)
const current = computed<TickerItem | null>(() => items.value[index.value] ?? items.value[0] ?? null)
const paused = computed(() => detail.value !== null)
const rotateMs = computed(() => Math.max(2, settings.value?.display?.rotateSeconds ?? 8) * 1000)
const rotateMs = computed(
() => Math.max(2, settings.value?.display?.rotateSeconds ?? DEFAULT_ROTATE_SECONDS) * 1000,
)
let timer = 0
@@ -14,7 +14,7 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX)
</script>
<template>
<PageFrame title="团体奖牌榜">
<PageFrame title="单位奖牌榜">
<div class="board" :style="{ '--row-font': rowFont }">
<div class="row head">
<span>名次</span>
+13 -1
View File
@@ -32,6 +32,15 @@ import type {
} from '@/shared'
import type { LiveEventSource } from './live'
/**
* `display.rotateSeconds` 缺配置时的兜底秒数。
*
* 必须与服务端同一个数:`dto/settings.go` 用 `atoiDefault(values["display.rotate_seconds"], 15)`,
* 所以「没配过」的库下发出来的就是 15。前端自己填 8 会让底栏 / 整页轮播比服务端口径快一倍,
* 现场只会觉得「说好的 15 秒怎么变快了」。
*/
export const DEFAULT_ROTATE_SECONDS = 15
/**
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
*
@@ -487,7 +496,10 @@ export function parseSettings(source: unknown): DisplaySettings {
title: str(settingValue(rec, 'display.title', 'display.title')),
subtitle: strOrNull(settingValue(rec, 'display.subtitle', 'display.subtitle')),
primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')),
rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8),
rotateSeconds: num(
settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'),
DEFAULT_ROTATE_SECONDS,
),
announce: announce === '' ? null : announce,
fontTitle: fontTitle === '' ? null : fontTitle,
fontBody: fontBody === '' ? null : fontBody,
+3 -1
View File
@@ -8,6 +8,7 @@ import type {
Team,
} from '@/shared'
import type { DisplaySettings } from './guards'
import { DEFAULT_ROTATE_SECONDS } from './guards'
/**
* 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。
@@ -268,7 +269,8 @@ const MOCK_SETTINGS: DisplaySettings = {
title: '2026 职工运动会',
subtitle: '积分榜 · 示例数据',
primaryColor: '#4fd8ff',
rotateSeconds: 8,
// 与服务端默认一致(dto/settings.go 的 display.rotate_seconds 兜底 15)。
rotateSeconds: DEFAULT_ROTATE_SECONDS,
// 示例数据里有 3 个已完成项目,取前两个演示双栏分栏。
liveEvents: [1, 2],
liveSource: 'manual',
+8
View File
@@ -47,6 +47,8 @@ export type TemplateErrorCode =
| 'block-mismatch'
| 'unexpected-else'
| 'duplicate-else'
/** `{{else if …}}` / `{{else anything}}`:设计稿 §3.3 只支持块内一层 else,链式写法必须报错 */
| 'unsupported-else-chain'
| 'unknown-block'
| 'empty-block-path'
/** 嵌套层数超过 MAX_TEMPLATE_DEPTH(安全阀,位置指向超出的那个块标记) */
@@ -243,6 +245,7 @@ const ERROR_TEXT: Record<TemplateErrorCode, string> = {
'block-mismatch': '结束标记与最近的块不配对',
'unexpected-else': '{{else}} 只能出现在 {{#if}} 块内',
'duplicate-else': '同一个 {{#if}} 块里出现了多个 {{else}}',
'unsupported-else-chain': '不支持 {{else if}} 这种链式写法({{else}} 只能出现一层),请改成嵌套的 {{#if}}',
'unknown-block': '不支持的块标记',
'empty-block-path': '块标记缺少路径',
'too-deep': '模板嵌套太深',
@@ -359,6 +362,11 @@ function tokenize(
tokens.push({ kind: 'close', block: keyword as 'each' | 'if', rawPath: '', raw: false, start: open, end, line, column })
} else if (inner === 'else') {
tokens.push({ kind: 'else', block: null, rawPath: '', raw: false, start: open, end, line, column })
} else if (/^else\s/.test(inner)) {
// `{{else if b}}` 这类链式写法:设计稿 §3.3 明确不支持(else 只有一层)。
// 绝不能当插值塞进 then 分支——那是整块内容静默消失、连警告都没有的坑,
// 所以这里直接给结构化错误,让预览能指出位置。
return { tokens, error: makeError('unsupported-else-chain', starts, open, `{{${inner}}}`) }
} else if (!triple && inner.startsWith('&')) {
// {{& x}} 与 {{{ x }}} 同义(设计稿 §3.4)
tokens.push({ kind: 'value', block: null, rawPath: inner.slice(1).trim(), raw: true, start: open, end, line, column })
@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parseAthleteStandings, parseStandingRows } from '../guards'
/**
* REST 兜底也要过 guards(P2 C39)。
*
* `api.standings.*` 只做 `{ok,data}` 拆包,字段缺失 / 类型不对时照样把裸值交出来;
* WS 快照那条路一直过 guards,REST 兜底却直接把返回值塞进 ref。
* 大屏是无人值守的,少一个字段就可能让模板整块渲染成空,两条路必须同口径。
*/
const USE_SCOREBOARD_SOURCE = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
test('个人榜 REST 数据经 guards 归一:缺字段补默认值,不抛异常', () => {
const rows = parseAthleteStandings([{ athleteId: 7, teamId: 2, athleteName: '张三' }])
assert.equal(rows.length, 1)
const row = rows[0]
assert.equal(row?.athleteId, 7)
assert.equal(row?.teamId, 2)
assert.equal(row?.athleteName, '张三')
assert.equal(row?.rank, 0)
assert.equal(row?.totalPoints, 0)
assert.equal(row?.shortName, null)
assert.equal(row?.logoPath, null)
})
test('脏行也不炸:非对象 / 缺字段都按默认值收下', () => {
assert.doesNotThrow(() => parseAthleteStandings([null, 1, 'x', []]))
assert.equal(parseAthleteStandings([null, 1, 'x']).length, 3)
assert.equal(parseAthleteStandings('not-an-array').length, 0)
})
test('单位榜 REST 数据同样过 guards', () => {
const rows = parseStandingRows([{ teamId: 1, teamName: '第一分公司' }])
assert.equal(rows[0]?.teamId, 1)
assert.equal(rows[0]?.rank, 0)
assert.equal(rows[0]?.medalTotal, 0)
assert.equal(rows[0]?.rankChange, null)
})
test('loadRest 的两条路都走 guards,不再把裸值塞进 ref', () => {
assert.match(USE_SCOREBOARD_SOURCE, /applyStandings\(parseStandingRows\(standingsResult\.value\)\)/)
assert.match(USE_SCOREBOARD_SOURCE, /athletes\.value = parseAthleteStandings\(athletesResult\.value\)/)
assert.doesNotMatch(USE_SCOREBOARD_SOURCE, /athletes\.value = athletesResult\.value/)
})
@@ -0,0 +1,55 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { DEFAULT_ROTATE_SECONDS, parseSettings } from '../guards'
import { createMockSnapshot } from '../mock'
/**
* 轮播间隔的两条口径(P2 B05 / B06)。
*
* B05:缺 `display.rotateSeconds` 时前端兜底 8 秒,而服务端默认(dto/settings.go 的
* atoiDefault(..., 15))是 15 秒——同一个「没配过」的库,底栏在大屏上快了近一倍。
* B06:整页轮播写死 20 秒,完全不读这个配置;06 §4 承诺两者「同间隔」。
*/
const USE_SCOREBOARD_SOURCE = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
const TICKER_SOURCE = readFileSync(
new URL('../components/TickerBar.vue', import.meta.url),
'utf8',
)
test('缺配置时按服务端默认 15 秒兜底', () => {
assert.equal(DEFAULT_ROTATE_SECONDS, 15)
assert.equal(parseSettings({}).display.rotateSeconds, 15)
})
test('配了就用配置的值(15 以外的值也照收)', () => {
assert.equal(parseSettings({ display: { rotateSeconds: 30 } }).display.rotateSeconds, 30)
// 服务端快照里也可能给扁平键,两种形状都要认。
assert.equal(parseSettings({ 'display.rotate_seconds': 5 }).display.rotateSeconds, 5)
})
test('示例数据与真服务端同口径(不再写 8)', () => {
assert.equal(createMockSnapshot().settings.display.rotateSeconds, DEFAULT_ROTATE_SECONDS)
})
test('整页轮播读 display.rotateSeconds,不再写死 20 秒', () => {
assert.doesNotMatch(USE_SCOREBOARD_SOURCE, /PAGE_ROTATE_MS/)
assert.doesNotMatch(USE_SCOREBOARD_SOURCE, /20_000/)
assert.match(USE_SCOREBOARD_SOURCE, /settings\.value\?\.display\?\.rotateSeconds/)
assert.match(USE_SCOREBOARD_SOURCE, /\}, pageRotateMs\.value\)/)
})
test('配置变化后重排计时器(setInterval 的周期不会自己变)', () => {
assert.match(USE_SCOREBOARD_SOURCE, /watch\(pageRotateMs, \(\) => \{\n\s*if \(pageTimer !== null\) startPageRotation\(\)/)
})
test('配置非法或过小时有下限,不会疯狂翻页', () => {
assert.match(USE_SCOREBOARD_SOURCE, /const MIN_ROTATE_SECONDS = 2/)
assert.match(USE_SCOREBOARD_SOURCE, /Math\.max\(MIN_ROTATE_SECONDS, safe\) \* 1000/)
})
test('底栏的兜底值同样来自共享常量', () => {
assert.match(TICKER_SOURCE, /settings\.value\?\.display\?\.rotateSeconds \?\? DEFAULT_ROTATE_SECONDS/)
assert.doesNotMatch(TICKER_SOURCE, /\?\? 8\)/)
})
@@ -102,3 +102,60 @@ test('行号按实际换行统计(\\r\\n 模板也不偏)', () => {
const result = renderTemplate('a\r\nb\r\n{{#each rows}}\r\nx\r\n', { rows: [] })
assert.equal(result.error?.line, 3)
})
/*
* `{{else if b}}`:设计稿 §3.3 明确只支持块内一层 {{else}},不支持链式 else。
* 以前它落到「当作插值」那条分支,被塞进 then 分支里——条件为假时整块内容**静默消失**,
* 连一条警告都没有(警告只在渲染到的节点上产生),现场只会看到「屏幕少了一块」。
* 现在必须走结构化错误,让预览能指出是哪一行。
*/
test('{{else if b}} 报 unsupported-else-chain,而不是被当成插值', () => {
const result = renderTemplate('{{#if a}}A{{else if b}}B{{/if}}', { a: false, b: true })
assert.equal(result.error?.code, 'unsupported-else-chain')
assert.equal(result.html, '', '结构错误时 html 恒为空,不渲染半截')
assert.deepEqual(result.warnings, [], '不该退化成「插值取不到值」的警告')
assert.equal(result.error?.line, 1)
})
test('条件为真也一样报错:错误要在解析期定死,不随数据变', () => {
const result = renderTemplate('{{#if a}}A{{else if b}}B{{/if}}', { a: true, b: true })
assert.equal(result.error?.code, 'unsupported-else-chain')
assert.equal(result.html, '')
})
test('错误信息给出原文、位置与替代写法', () => {
const parsed = parseTemplate('第一行\n{{#if x}}1{{else if y}}2{{/if}}')
assert.equal(parsed.error?.code, 'unsupported-else-chain')
assert.equal(parsed.error?.line, 2)
assert.ok(parsed.error?.message.includes('{{else if y}}'), '要带上原文,才知道改哪一处')
assert.ok(parsed.error?.message.includes('{{#if}}'), '要给出「改成嵌套 if」的替代写法')
assert.deepEqual(parsed.ast, [])
})
test('带空白的写法({{ else if b }})同样拦下', () => {
assert.equal(
renderTemplate('{{#if a}}A{{ else if b }}B{{/if}}', { a: false }).error?.code,
'unsupported-else-chain',
)
})
test('其它链式写法({{else each rows}})也拦下,不静默', () => {
assert.equal(
renderTemplate('{{#if a}}A{{else each rows}}B{{/if}}', { a: false }).error?.code,
'unsupported-else-chain',
)
})
test('块外的 {{else if}} 也报错(不落进插值分支)', () => {
assert.equal(renderTemplate('{{else if b}}', {}).error?.code, 'unsupported-else-chain')
})
test('正常的 {{else}} 不受影响', () => {
const result = renderTemplate('{{#if a}}1{{else}}2{{/if}}', { a: false })
assert.equal(result.error, null)
assert.equal(result.html, '2')
})
+3 -1
View File
@@ -5,7 +5,7 @@ import type { TeamBreakdown } from '@/shared'
*/
export const DISPLAY_PAGES = [
{ key: 'standings', title: '总积分榜' },
{ key: 'team-medals', title: '团体奖牌榜' },
{ key: 'team-medals', title: '单位奖牌榜' },
{ key: 'athlete-standings', title: '个人积分奖牌榜' },
{ key: 'event-results', title: '分项成绩' },
{ key: 'live', title: '实时赛况' },
@@ -25,7 +25,9 @@ const PAGE_ALIASES: Record<string, DisplayPageKey> = {
medals: 'team-medals',
medal: 'team-medals',
奖牌榜: 'team-medals',
// 「团体奖牌榜」是旧叫法(容易与「团体项目的奖牌」混淆),保留为别名让老链接/老配置继续可用。
团体奖牌榜: 'team-medals',
单位奖牌榜: 'team-medals',
'athlete-standings': 'athlete-standings',
athletes: 'athlete-standings',
individual: 'athlete-standings',
+34 -5
View File
@@ -10,6 +10,8 @@ import type {
StandingRow,
} from '@/shared'
import {
DEFAULT_ROTATE_SECONDS,
parseAthleteStandings,
parseLiveFeed,
parseSettings,
parseSnapshot,
@@ -32,8 +34,8 @@ import type {
} from './types'
import { prefersReducedMotion } from './useReducedMotion'
/** 整页轮播间隔(06 第 4 节:后台可切换为整页轮播,每页 20s)。 */
const PAGE_ROTATE_MS = 20_000
/** 轮播间隔下限(秒):配置成 0 或负数时按 2 秒兜底,不然会疯狂翻页。 */
const MIN_ROTATE_SECONDS = 2
/** 名次升降箭头保留时长(06 第 7 节:2s 后淡出)。 */
const RANK_DELTA_MS = 2_000
@@ -114,6 +116,22 @@ const rotationPages = computed<DisplayPageKey[]>(() =>
),
)
/**
* 整页轮播间隔:与服务端 `display.rotateSeconds` 同源(06 第 4 节「与底栏同间隔」)。
*
* 以前写死 20s,管理员把轮播改成 15s 或 30s 只影响底栏,整页轮播毫无反应。
* 缺配置 / 配置非法时按服务端默认值兜底(`dto/settings.go` 的 rotate_seconds 兜底 15),
* 并结合下限,避免配成 0 时疯狂翻页。
*/
const pageRotateMs = computed(() => {
const seconds = settings.value?.display?.rotateSeconds
const safe =
typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
? seconds
: DEFAULT_ROTATE_SECONDS
return Math.max(MIN_ROTATE_SECONDS, safe) * 1000
})
function compareStanding(a: StandingRow, b: StandingRow): number {
return a.rank - b.rank || b.totalPoints - a.totalPoints
}
@@ -305,6 +323,10 @@ function hydrateFromCache(): void {
* 大屏不请求 /api/settings(admin-only,guest 必然 403),配置只认 WS 快照与本地缓存;
* 快照比 REST 权威(REST 没有 version),且请求飞行途中可能已有快照到达,
* 所以是否套用 REST 必须在 await 之后重新判断,不能沿用发请求前的结论。
*
* 两条路都必须过 guards:`api.standings.*` 只做了 JSON 解析(`{ok,data}` 拆包),
* 字段缺失 / 类型不对时它照样把裸值交出来,而大屏是无人值守的——
* 少一个字段就可能让整页模板渲染成空。WS 快照那条路一直是过 guards 的,两边口径要一致。
*/
async function loadRest(): Promise<boolean> {
const [standingsResult, athletesResult] = await Promise.allSettled([
@@ -315,11 +337,11 @@ async function loadRest(): Promise<boolean> {
let ok = false
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
applyStandings(standingsResult.value)
applyStandings(parseStandingRows(standingsResult.value))
ok = true
}
if (athletesResult.status === 'fulfilled') {
athletes.value = athletesResult.value
athletes.value = parseAthleteStandings(athletesResult.value)
ok = true
}
return ok
@@ -390,7 +412,7 @@ function startPageRotation(): void {
const current = order.indexOf(page.value)
const next = order[(current + 1) % order.length]
if (next !== undefined) page.value = next
}, PAGE_ROTATE_MS)
}, pageRotateMs.value)
}
function setLocked(value: boolean): void {
@@ -492,6 +514,13 @@ watch(
{ immediate: true },
)
// 轮播间隔也来自 settings(display.rotateSeconds),而 setInterval 的周期在建表时就固定了:
// 设置到达 / 被改后必须重排计时器,否则首屏(快照还没到)会一直按兜底间隔翻。
// 只在轮播真在跑时才动它——锁定、全屏公告、明细覆盖期间本来就该停着。
watch(pageRotateMs, () => {
if (pageTimer !== null) startPageRotation()
})
/**
* 服务端把当前公告持久化在设置里(display.announce),快照与设置接口都带它。
*