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:
AskaEth committed 2026-09-09 21:41:08 +08:00
1 parent 4d56c73ae6
commit 2c9ff65fc4
42 files changed
+1271 -275

No files matched your search

+4 -2
View File
@@ -16,9 +16,11 @@ const DEFAULTS = {
restartDelayMs: 3000,
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
maxRestarts: 0,
// 优雅关闭握手:服务端需实现该回环接口,未实现时回退到进程信号
// 优雅关闭握手:服务端需实现该回环接口并校验 X-Shutdown-Token,
// 未实现(404/连接失败)时回退到进程信号
shutdownPath: '/api/shutdown',
shutdownTimeoutMs: 12000,
// 服务端受理关闭请求后等待其自行退出的上限,超过则强杀
shutdownTimeoutMs: 5000,
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
healthyResetMs: 60000
},
+54
View File
@@ -15,6 +15,7 @@ const { loadConfig, resolveExecutable } = require('./config')
const { LogBus } = require('./log-bus')
const { ServiceSupervisor } = require('./service')
const { DisplayManager } = require('./displays')
const { fetchDisplayInstances } = require('./instances')
const { createTrayIcon } = require('./tray-icon')
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
@@ -39,6 +40,9 @@ function main() {
let quitting = false
let shutdownDone = false
let exePath = null
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
let instancesTimer = null
let instancesLastReason = null
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
let accountBusy = false
@@ -118,7 +122,12 @@ function main() {
log.push('console', '管理窗口已最小化到托盘(服务端继续运行)')
})
// 实例看板只在窗口可见时轮询,隐藏/关闭即停表
adminWindow.on('show', startInstancesPolling)
adminWindow.on('hide', stopInstancesPolling)
adminWindow.on('closed', () => {
stopInstancesPolling()
adminWindow = null
})
}
@@ -130,6 +139,48 @@ function main() {
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
}
function serverHost() {
if (service) return service.healthHost()
const host = cfg.values.server.host || '127.0.0.1'
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
}
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
// 失败只按状态变化记一次日志,避免未登录/服务未启动时刷屏。
async function pollDisplayInstances() {
let result
try {
result = await fetchDisplayInstances({ host: serverHost(), port: cfg.values.server.port })
} catch (err) {
result = { ok: false, reason: 'error', message: `读取大屏实例失败:${err.message}` }
}
if (result.ok) {
if (instancesLastReason) {
instancesLastReason = null
log.push('console', `大屏实例列表恢复读取(${result.instances.length} 个实例)`)
}
} else if (result.reason !== instancesLastReason) {
instancesLastReason = result.reason
log.push('console', `读取大屏实例失败:${result.message}`)
}
sendToAdmin('display:instances', result)
return result
}
function startInstancesPolling() {
if (instancesTimer) return
pollDisplayInstances()
instancesTimer = setInterval(pollDisplayInstances, 5000)
}
function stopInstancesPolling() {
if (!instancesTimer) return
clearInterval(instancesTimer)
instancesTimer = null
}
function showServerAdmin() {
if (!serverAdminWindow || serverAdminWindow.isDestroyed()) {
createServerAdminWindow()
@@ -352,6 +403,7 @@ function main() {
showServerAdmin()
return true
})
ipcMain.handle('display:instances-get', () => pollDisplayInstances())
// 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道
ipcMain.handle('window:minimize', (event) => {
@@ -476,6 +528,7 @@ function main() {
event.preventDefault()
const finish = () => {
stopInstancesPolling()
if (displays) displays.closeAll()
if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId)
if (log) log.close()
@@ -491,6 +544,7 @@ function main() {
})
app.on('quit', () => {
stopInstancesPolling()
if (service) service.dispose()
})
}
+87
View File
@@ -0,0 +1,87 @@
'use strict'
const http = require('http')
const { session } = require('electron')
// 大屏实例看板:复用服务端管理面板的登录态,不另造一套登录逻辑。
// 管理面板窗口与 Console 同属默认 session,登录后 scoreboard_session 就存在这里,
// 主进程取同一张 Cookie 访问 GET /api/display/instances(记录员及以上可读)。
// Cookie 名与服务端 server/internal/auth/auth.go 的 CookieSession 一致。
const SESSION_COOKIE = 'scoreboard_session'
const REQUEST_TIMEOUT_MS = 3000
function requestJSON({ host, port, path, cookie }) {
return new Promise((resolve) => {
const request = http.get(
{
host,
port,
path,
timeout: REQUEST_TIMEOUT_MS,
headers: {
Cookie: cookie,
Accept: 'application/json',
Connection: 'close'
}
},
(response) => {
const chunks = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', () => {
resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') })
})
}
)
request.on('timeout', () => request.destroy(new Error('请求超时')))
request.on('error', (err) => resolve({ error: err }))
})
}
async function readSessionCookie(url) {
try {
const cookies = await session.defaultSession.cookies.get({ url })
const hit = cookies.find((item) => item.name === SESSION_COOKIE)
return hit ? `${hit.name}=${hit.value}` : null
} catch {
return null
}
}
// 成功返回 { ok: true, instances };失败返回 { ok: false, reason, message },
// message 直接作为界面占位文案,reason 供调用方决定要不要记日志(避免失败时刷屏)。
async function fetchDisplayInstances({ host, port }) {
const cookie = await readSessionCookie(`http://${host}:${port}/`)
if (!cookie) {
return { ok: false, reason: 'unauthenticated', message: '未登录,请先在服务端管理面板登录(记录员及以上)' }
}
const result = await requestJSON({ host, port, path: '/api/display/instances', cookie })
if (result.error) {
if (result.error.code === 'ECONNREFUSED' || result.error.code === 'ECONNRESET') {
return { ok: false, reason: 'offline', message: '服务端未运行,暂时无法读取大屏实例' }
}
return { ok: false, reason: 'error', message: `读取大屏实例失败:${result.error.message}` }
}
if (result.status === 401) {
return { ok: false, reason: 'unauthenticated', message: '登录已失效,请重新登录服务端管理面板' }
}
if (result.status === 403) {
return { ok: false, reason: 'forbidden', message: '当前账号权限不足(需要记录员及以上)' }
}
if (result.status === 404) {
return { ok: false, reason: 'unsupported', message: '服务端版本较旧,未提供大屏实例接口' }
}
if (result.status !== 200) {
return { ok: false, reason: 'error', message: `读取大屏实例失败:HTTP ${result.status}` }
}
try {
const payload = JSON.parse(result.body)
return { ok: true, instances: Array.isArray(payload.data) ? payload.data : [] }
} catch (err) {
return { ok: false, reason: 'error', message: `大屏实例响应无法解析:${err.message}` }
}
}
module.exports = { fetchDisplayInstances }
+39 -19
View File
@@ -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')