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:
AskaEth committed 2026-09-09 21:06:40 +08:00
1 parent 8dae63ee70
commit 7f9701c465
61 files changed
+3844 -333

No files matched your search

+77
View File
@@ -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 }
+6 -1
View File
@@ -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
View File
@@ -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({