feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对) 与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的 重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端 - export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep), 自动、手动、发布前三类备份共用同一把互斥锁与滚动清理 - realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行, 跨站需命中 server.allowedOrigins) - console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀), 服务管理器新增「已连接的大屏实例」卡片 - deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空, 导致最近这次构建的 exe 里根本没注入署名 - docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
This commit is contained in:
1 parent
4d56c73ae6
commit
2c9ff65fc4
42 files changed
+1271
-275
No files matched your search
+39
-19
@@ -1,5 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const path = require('path')
|
||||
@@ -14,12 +15,14 @@ const STATE_LABEL = {
|
||||
crashed: '异常退出'
|
||||
}
|
||||
|
||||
// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext
|
||||
// 的关闭流程;请求失败或接口未实现时回退到进程信号。
|
||||
// 优雅关闭握手:POST server.shutdownPath 并带上启动时下发的 token,
|
||||
// 让服务端自己走 signal.NotifyContext 的关闭流程;请求失败或接口未实现时回退到强杀。
|
||||
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
|
||||
const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000
|
||||
// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000
|
||||
const SHUTDOWN_TOKEN_ENV = 'SCOREBOARD_SHUTDOWN_TOKEN'
|
||||
const SHUTDOWN_TOKEN_HEADER = 'X-Shutdown-Token'
|
||||
const SHUTDOWN_REQUEST_TIMEOUT_MS = 3000
|
||||
// 服务端收到关闭请求后承诺 3 秒内退出,客户端留 5 秒余量再强杀
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000
|
||||
// 握手不可用时信号已能结束进程,不必再等满超时
|
||||
const FALLBACK_FORCE_KILL_MS = 1500
|
||||
// 连续健康这么久才认为崩溃计数可以清零
|
||||
@@ -48,6 +51,10 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.healthTimer = null
|
||||
this.restartTimer = null
|
||||
this.forceKillTimer = null
|
||||
// 本次进程的关闭握手 token 与握手结果,仅用于日志区分「优雅退出 / 被强杀」
|
||||
this.shutdownToken = null
|
||||
this.gracefulAccepted = false
|
||||
this.forcedKill = false
|
||||
}
|
||||
|
||||
get status() {
|
||||
@@ -103,16 +110,23 @@ class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
this.lastError = null
|
||||
this.healthySince = null
|
||||
this.gracefulAccepted = false
|
||||
this.forcedKill = false
|
||||
this.startedAt = new Date().toISOString()
|
||||
this.setState('starting')
|
||||
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
|
||||
|
||||
// 每次拉起都换一把新 token,只通过环境变量交给这一个子进程,
|
||||
// 停止时凭它调用服务端的 /api/shutdown
|
||||
this.shutdownToken = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
let child
|
||||
try {
|
||||
child = spawn(this.exePath, args, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, [SHUTDOWN_TOKEN_ENV]: this.shutdownToken }
|
||||
})
|
||||
} catch (err) {
|
||||
this.setState('crashed', { lastError: `启动失败:${err.message}` })
|
||||
@@ -157,7 +171,10 @@ class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
|
||||
if (this.stopRequested) {
|
||||
this.log.push('console', `服务端已停止(${reason})`)
|
||||
const how = this.forcedKill ? '已被强制结束' : this.gracefulAccepted ? '已优雅退出' : '已停止'
|
||||
this.log.push('console', `服务端${how}(${reason})`)
|
||||
this.gracefulAccepted = false
|
||||
this.forcedKill = false
|
||||
this.setState('stopped', { lastError: null })
|
||||
return
|
||||
}
|
||||
@@ -198,24 +215,25 @@ class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
this.stopRequested = true
|
||||
this.setState('stopping')
|
||||
this.log.push('console', '正在停止服务端…')
|
||||
this.log.push('console', '正在优雅关闭服务端…')
|
||||
|
||||
child.once('exit', () => resolve())
|
||||
|
||||
const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
||||
// 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里
|
||||
this.armForceKill(timeoutMs)
|
||||
const graceMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
||||
|
||||
this.requestShutdown().then((accepted) => {
|
||||
// 请求期间进程可能已经退了,handleExit 会清掉兜底定时器
|
||||
if (this.child !== child) return
|
||||
if (accepted) {
|
||||
this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`)
|
||||
this.gracefulAccepted = true
|
||||
this.log.push('console', `服务端已接受优雅关闭请求,最多等待 ${Math.round(graceMs / 1000)} 秒自行退出`)
|
||||
this.armForceKill(graceMs)
|
||||
return
|
||||
}
|
||||
// 握手不可用:服务端还没实现该接口,或进程已无响应。
|
||||
// 握手不可用:服务端还是老版本没有该接口,或进程已无响应。
|
||||
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
|
||||
// 真正的优雅关闭依赖服务端补上 shutdownPath 接口。
|
||||
this.log.push('console', '关闭接口不可用,退回进程信号')
|
||||
// 真正的优雅关闭依赖服务端实现 shutdownPath 接口。
|
||||
this.log.push('console', '优雅关闭接口不可用,退回强制结束')
|
||||
this.armForceKill(FALLBACK_FORCE_KILL_MS)
|
||||
try {
|
||||
child.kill()
|
||||
@@ -226,9 +244,9 @@ class ServiceSupervisor extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
// 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程,
|
||||
// 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。
|
||||
// 依赖:服务端实现 POST <server.shutdownPath>(2xx 表示已受理)。
|
||||
// 优雅关闭握手:带启动时下发的 X-Shutdown-Token 请求服务端退出,
|
||||
// 2xx 表示已受理。老版本服务端没有该接口(404)或连不上时返回 false,
|
||||
// 由调用方回退到强杀。
|
||||
requestShutdown() {
|
||||
return new Promise((resolve) => {
|
||||
const body = Buffer.from('{}')
|
||||
@@ -242,13 +260,14 @@ class ServiceSupervisor extends EventEmitter {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': body.length,
|
||||
[SHUTDOWN_TOKEN_HEADER]: this.shutdownToken || '',
|
||||
Connection: 'close'
|
||||
}
|
||||
},
|
||||
(response) => {
|
||||
response.resume()
|
||||
const accepted = response.statusCode >= 200 && response.statusCode < 300
|
||||
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`)
|
||||
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}(按老版本处理)`)
|
||||
resolve(accepted)
|
||||
}
|
||||
)
|
||||
@@ -266,6 +285,7 @@ class ServiceSupervisor extends EventEmitter {
|
||||
this.forceKillTimer = setTimeout(() => {
|
||||
this.forceKillTimer = null
|
||||
if (!this.child || !this.pid) return
|
||||
this.forcedKill = true
|
||||
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
|
||||
try {
|
||||
this.child.kill('SIGKILL')
|
||||
|
||||
Reference in new issue
Block a user