Files
Scoreboard/console/main/service.js
T
AskaEth 7d5d94f815 feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)
- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
2026-09-09 18:36:40 +08:00

265 lines
7.3 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 fs = require('fs')
const http = require('http')
const path = require('path')
const { EventEmitter } = require('events')
const { spawn } = require('child_process')
const STATE_LABEL = {
stopped: '已停止',
starting: '启动中',
running: '运行中',
stopping: '停止中',
crashed: '异常退出'
}
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log }) {
super()
this.values = values
this.server = values.server
this.exePath = exePath
this.log = log
this.child = null
this.state = 'stopped'
this.healthy = false
this.pid = null
this.restarts = 0
this.startedAt = null
this.lastError = null
this.stopRequested = false
this.healthTimer = null
this.restartTimer = null
this.forceKillTimer = null
}
get status() {
const host = this.healthHost()
return {
state: this.state,
stateLabel: STATE_LABEL[this.state] || this.state,
healthy: this.healthy,
pid: this.pid,
restarts: this.restarts,
startedAt: this.startedAt,
lastError: this.lastError,
exePath: this.exePath,
url: `http://${host}:${this.server.port}`,
adminUrl: `http://${host}:${this.server.port}/admin`
}
}
// 服务端可能监听 0.0.0.0,本机探测要走回环地址
healthHost() {
const host = this.server.host || '127.0.0.1'
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
}
emitStatus() {
this.emit('status', this.status)
}
setState(state, patch = {}) {
const changed = this.state !== state
this.state = state
Object.assign(this, patch)
if (changed) {
this.log.push('console', `服务状态:${STATE_LABEL[state] || state}${this.lastError ? `(${this.lastError})` : ''}`)
}
this.emitStatus()
}
start() {
if (this.child || this.state === 'starting' || this.state === 'running') return this.status
this.stopRequested = false
if (!this.exePath || !fs.existsSync(this.exePath)) {
this.setState('crashed', {
lastError: `未找到服务端程序:${this.exePath || this.server.exe}`
})
return this.status
}
const args = Array.isArray(this.server.args) ? this.server.args : []
// 工作目录取 exe 所在目录:部署包里 config.yaml / data/ 都跟 exe 平级
const cwd = path.dirname(this.exePath)
this.lastError = null
this.startedAt = new Date().toISOString()
this.setState('starting')
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
let child
try {
child = spawn(this.exePath, args, {
cwd,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (err) {
this.setState('crashed', { lastError: `启动失败:${err.message}` })
this.scheduleRestart()
return this.status
}
this.child = child
this.pid = child.pid
if (child.stdout) {
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => this.log.append('server', chunk))
}
if (child.stderr) {
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => this.log.append('server!', chunk))
}
child.on('error', (err) => {
this.log.push('console', `服务端进程错误:${err.message}`)
this.setState('crashed', { lastError: err.message })
})
child.on('exit', (code, signal) => this.handleExit(code, signal))
this.setState('running')
this.startHealthCheck()
return this.status
}
handleExit(code, signal) {
if (this.forceKillTimer) {
clearTimeout(this.forceKillTimer)
this.forceKillTimer = null
}
this.stopHealthCheck()
this.child = null
this.pid = null
this.healthy = false
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
if (this.stopRequested) {
this.log.push('console', `服务端已停止(${reason})`)
this.setState('stopped', { lastError: null })
return
}
this.setState('crashed', { lastError: `服务端意外退出(${reason})` })
this.scheduleRestart()
}
scheduleRestart() {
const max = Number(this.server.maxRestarts) || 0
if (max > 0 && this.restarts >= max) {
this.log.push('console', `已达到最大重启次数 ${max},不再自动拉起`)
return
}
const delay = (Number(this.server.restartDelayMs) || 3000) * Math.min(this.restarts + 1, 5)
this.restarts += 1
this.log.push('console', `${Math.round(delay / 1000)} 秒后自动重启服务端(第 ${this.restarts} 次)`)
this.emitStatus()
if (this.restartTimer) clearTimeout(this.restartTimer)
this.restartTimer = setTimeout(() => {
this.restartTimer = null
if (!this.stopRequested) this.start()
}, delay)
}
stop() {
return new Promise((resolve) => {
if (this.restartTimer) {
clearTimeout(this.restartTimer)
this.restartTimer = null
}
const child = this.child
if (!child) {
this.setState('stopped', { lastError: null })
resolve()
return
}
this.stopRequested = true
this.setState('stopping')
this.log.push('console', '正在停止服务端…')
child.once('exit', () => resolve())
try {
child.kill()
} catch (err) {
this.log.push('console', `发送停止信号失败:${err.message}`)
}
// Go 服务端有 10s 优雅关闭窗口,超过 5s 仍未退出就强杀,避免 Console 退不干净
this.forceKillTimer = setTimeout(() => {
if (this.child && this.pid) {
this.log.push('console', '服务端未在 5 秒内退出,强制结束')
try {
this.child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
}
}, 5000)
})
}
async restart() {
await this.stop()
this.restarts = 0
return this.start()
}
startHealthCheck() {
this.stopHealthCheck()
const interval = Number(this.server.healthIntervalMs) || 5000
const tick = () => this.probe()
setTimeout(tick, 800)
this.healthTimer = setInterval(tick, interval)
}
stopHealthCheck() {
if (this.healthTimer) {
clearInterval(this.healthTimer)
this.healthTimer = null
}
}
probe() {
if (!this.child) return
const request = http.get(
{
host: this.healthHost(),
port: this.server.port,
path: '/api/health',
timeout: 2500
},
(response) => {
response.resume()
const ok = response.statusCode === 200
this.setHealthy(ok)
}
)
request.on('timeout', () => request.destroy())
request.on('error', () => this.setHealthy(false))
}
setHealthy(ok) {
if (this.healthy === ok) return
this.healthy = ok
this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)')
this.emitStatus()
}
dispose() {
if (this.restartTimer) clearTimeout(this.restartTimer)
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
this.stopHealthCheck()
this.removeAllListeners()
}
}
module.exports = { ServiceSupervisor, STATE_LABEL }