fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力 - realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份 - httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表 - web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换 - console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复 - docs: 用户文档三份 + README - 主代理: 破纪录加分接线(levels 未加载导致恒 0)
This commit is contained in:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -0,0 +1,77 @@
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const { spawn } = require('child_process')
|
||||
|
||||
// 用户名 / 密码长度上限:passwd 自己也会限制,这里只是把明显不合法的挡在 UI 侧
|
||||
const FIELD_MAX = 64
|
||||
|
||||
// 返回错误文案,null 表示通过。required 为 false 时允许空串(= 不改 / 随机生成)
|
||||
function validateAccountField(value, label, required) {
|
||||
if (typeof value !== 'string') return `${label}必须是文本`
|
||||
if (value === '') return required ? `${label}不能为空` : null
|
||||
// spawn 用参数数组、不经 shell,不存在命令注入;但空格会把参数拆成两段、
|
||||
// 换行会截断输出,所以在入口直接拒掉,别让 passwd 报看不懂的错。
|
||||
if (/\s/.test(value)) return `${label}不能包含空格、制表符或换行`
|
||||
if (value.length > FIELD_MAX) return `${label}最多 ${FIELD_MAX} 个字符`
|
||||
return null
|
||||
}
|
||||
|
||||
// 空的新用户名 / 空密码直接传空串:passwd 的语义就是「留空则不改 / 随机生成」
|
||||
function buildPasswdArgs({ configArg, currentUsername, newUsername, password }) {
|
||||
return [
|
||||
'passwd',
|
||||
'-config', configArg,
|
||||
'-user', currentUsername,
|
||||
'-new-username', newUsername,
|
||||
'-password', password
|
||||
]
|
||||
}
|
||||
|
||||
// 跑一次 passwd,实时把 stdout/stderr 交给 onOutput,结束时返回完整输出与退出码
|
||||
function runPasswd({ exePath, args, onOutput }) {
|
||||
return new Promise((resolve) => {
|
||||
let child
|
||||
try {
|
||||
// cwd 取 exe 所在目录:config.yaml 与 data/ 都跟 exe 平级
|
||||
child = spawn(exePath, args, {
|
||||
cwd: path.dirname(exePath),
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: `启动 passwd 失败:${err.message}`, output: '' })
|
||||
return
|
||||
}
|
||||
|
||||
let output = ''
|
||||
let settled = false
|
||||
const finish = (result) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const onChunk = (chunk) => {
|
||||
const text = String(chunk)
|
||||
output += text
|
||||
if (typeof onOutput === 'function') onOutput(text)
|
||||
}
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', onChunk)
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', onChunk)
|
||||
}
|
||||
|
||||
child.on('error', (err) => finish({ ok: false, error: err.message, output }))
|
||||
// 用 close 而不是 exit:等两个 stdio 都排空,输出才是完整的
|
||||
child.on('close', (code, signal) => {
|
||||
finish({ ok: code === 0, exitCode: code, signal: signal || null, output })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { FIELD_MAX, validateAccountField, buildPasswdArgs, runPasswd }
|
||||
@@ -26,7 +26,8 @@ const DEFAULTS = {
|
||||
// all-secondary | all | specified
|
||||
selection: 'all-secondary',
|
||||
displays: [],
|
||||
pages: ['standings', 'medals', 'individual', 'results', 'records'],
|
||||
// 只能写大屏实际支持的页名(见 web/src/display/types.ts 的 PAGE_ALIASES)
|
||||
pages: ['standings', 'medals', 'individual', 'results'],
|
||||
pageParam: 'page',
|
||||
kiosk: true,
|
||||
ignoreMouse: true,
|
||||
@@ -35,6 +36,10 @@ const DEFAULTS = {
|
||||
waitForServerMs: 20000,
|
||||
backgroundColor: '#0b0f1a'
|
||||
},
|
||||
startup: {
|
||||
// 打包后随系统启动(doc 10 §4.3);开发态不注册
|
||||
openAtLogin: true
|
||||
},
|
||||
admin: {
|
||||
width: 1040,
|
||||
height: 760
|
||||
|
||||
+71
-2
@@ -1,5 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const {
|
||||
app,
|
||||
@@ -15,6 +16,7 @@ const { LogBus } = require('./log-bus')
|
||||
const { ServiceSupervisor } = require('./service')
|
||||
const { DisplayManager } = require('./displays')
|
||||
const { createTrayIcon } = require('./tray-icon')
|
||||
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
|
||||
|
||||
// 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
@@ -36,6 +38,9 @@ function main() {
|
||||
let displaysEnabled = false
|
||||
let quitting = false
|
||||
let shutdownDone = false
|
||||
let exePath = null
|
||||
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
|
||||
let accountBusy = false
|
||||
|
||||
function resolveConfigPath() {
|
||||
if (process.env.SCOREBOARD_CONSOLE_CONFIG) {
|
||||
@@ -87,7 +92,7 @@ function main() {
|
||||
height,
|
||||
minWidth: 860,
|
||||
minHeight: 600,
|
||||
title: 'Scoreboard Console · 启动管理',
|
||||
title: '赛事积分榜 · 服务管理',
|
||||
backgroundColor: '#0f1420',
|
||||
show: false,
|
||||
// 标题栏由页面自绘;titleBarStyle 只是兜底,防止某些平台忽略 frame:false
|
||||
@@ -143,7 +148,7 @@ function main() {
|
||||
height,
|
||||
minWidth: 900,
|
||||
minHeight: 620,
|
||||
title: 'Scoreboard · 服务端管理面板',
|
||||
title: '赛事积分榜 · 服务端管理面板',
|
||||
backgroundColor: '#161616',
|
||||
show: false,
|
||||
frame: false,
|
||||
@@ -253,8 +258,71 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号
|
||||
function passwdConfigArg() {
|
||||
const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : []
|
||||
const index = args.indexOf('-config')
|
||||
if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) {
|
||||
return args[index + 1]
|
||||
}
|
||||
return 'config.yaml'
|
||||
}
|
||||
|
||||
// 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP,
|
||||
// 所以不需要任何登录态,这就是面板能「免验证」的原因。
|
||||
async function resetAdminAccount(event, payload) {
|
||||
if (accountBusy) return { ok: false, error: '上一次重置还没结束,请稍候再试' }
|
||||
// IPC 入参一律当不可信处理
|
||||
if (payload === null || typeof payload !== 'object') payload = {}
|
||||
|
||||
const currentUsername = typeof payload.currentUsername === 'string' ? payload.currentUsername.trim() : ''
|
||||
const newUsername = typeof payload.newUsername === 'string' ? payload.newUsername.trim() : ''
|
||||
const password = typeof payload.password === 'string' ? payload.password : ''
|
||||
|
||||
const invalid =
|
||||
validateAccountField(currentUsername, '当前用户名', true) ||
|
||||
validateAccountField(newUsername, '新用户名', false) ||
|
||||
validateAccountField(password, '新密码', false)
|
||||
if (invalid) return { ok: false, error: invalid }
|
||||
|
||||
const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path).path
|
||||
if (!exe || !fs.existsSync(exe)) {
|
||||
return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` }
|
||||
}
|
||||
|
||||
const configArg = passwdConfigArg()
|
||||
const args = buildPasswdArgs({ configArg, currentUsername, newUsername, password })
|
||||
const sender = event.sender
|
||||
|
||||
accountBusy = true
|
||||
// 日志里不能带明文密码
|
||||
log.push(
|
||||
'console',
|
||||
`重置管理员账号:${exe} passwd -config ${configArg} -user ${currentUsername}` +
|
||||
`${newUsername ? ` -new-username ${newUsername}` : ''}(密码已隐藏)`
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await runPasswd({
|
||||
exePath: exe,
|
||||
args,
|
||||
onOutput: (text) => {
|
||||
if (!sender.isDestroyed()) sender.send('account:output', text)
|
||||
}
|
||||
})
|
||||
const reason = result.error
|
||||
? result.error
|
||||
: `退出码 ${result.exitCode}${result.signal ? `,信号 ${result.signal}` : ''}`
|
||||
log.push('console', `重置管理员账号${result.ok ? '完成' : '失败'}(${reason})`)
|
||||
return result
|
||||
} finally {
|
||||
accountBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
ipcMain.handle('service:get-status', () => statusSnapshot())
|
||||
ipcMain.handle('account:reset', (event, payload) => resetAdminAccount(event, payload))
|
||||
ipcMain.handle('service:start', () => {
|
||||
service.start()
|
||||
return statusSnapshot()
|
||||
@@ -323,6 +391,7 @@ function main() {
|
||||
if (cfg.warning) log.push('console', cfg.warning)
|
||||
|
||||
const resolved = resolveExecutable(cfg.values.server.exe, cfg.path)
|
||||
exePath = resolved.path
|
||||
log.push('console', `服务端程序:${resolved.path}`)
|
||||
|
||||
service = new ServiceSupervisor({
|
||||
|
||||
Reference in new issue
Block a user