## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
486 lines
15 KiB
JavaScript
486 lines
15 KiB
JavaScript
'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)
|
||
})
|