Files
Scoreboard/console/main/service.test.js
T
AskaEth 6841cf49aa 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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

486 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)
})