feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)

- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
AskaEth committed 2026-09-09 18:36:40 +08:00
1 parent a61d793d79
commit 7d5d94f815
76 files changed
+10131

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
'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 }