fix: 数据目录移出安装目录,修掉「覆盖安装/卸载即丢数据」

根因:数据目录是 <安装目录>\data(config.yaml 的 dataDir 相对服务端工作目录),
而 electron-builder 的卸载器结尾执行 RMDir /r $INSTDIR,且覆盖安装会先跑旧卸载器
(installSection.nsh:52 的 uninstallOldVersion)→ 赛事库/规则/用户/审计/上传图片
连同安装目录被整个删除,再装新版。卸载一次同样如此。

- 服务端新增 -data <dir>(主命令、backup、passwd 三处),非空时覆盖 config.yaml 的
  dataDir;不传时行为完全不变(兼容便携/手工运行)
- Console 把数据指到当前用户目录 %LOCALAPPDATA%\ScoreboardConsole\data(卸载器不会碰
  这里;没有用 app.getPath('userData'),因为 %APPDATA%\<AppName> 正是被删的那个)
- 一次性迁移:目标为空且 <安装目录>\data 有数据时整体改名过去;两边都有数据时不动作
  只记警告;任何失败都保留原地数据、绝不删除
- passwd 也带 -data(否则重置的是安装目录下那个空库里的账号)
- deploy/backup.bat 指向同一数据目录
- 文档:部署指南新增「数据放在哪里(升级/卸载都不会删)」+ 备份/恢复/迁移路径改写,
  上线检查清单与使用手册同步

自检:服务端 12 包全绿;console 语法 14 文件 + datadir 单测 11/11;backup.bat 保持纯 ASCII。
This commit is contained in:
AskaEth committed 2026-09-10 09:17:56 +08:00
1 parent 6483acf9ad
commit 005b51b2ec
18 files changed
+626 -64

No files matched your search

+26 -2
View File
@@ -20,6 +20,8 @@ console/
├── main/
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
│ ├── config.js # 配置加载与合并、exe 路径解析
│ ├── datadir.js # 数据目录解析 + 旧数据一次性迁移
│ ├── datadir.test.js # 数据目录的单元测试(node --test main/datadir.test.js)
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
@@ -62,12 +64,31 @@ scoreboard-windows-amd64/
├── scoreboard.exe
├── config.yaml
├── rules/
├── data/
└── logs/console.log # 运行时生成
```
数据不在安装目录里,默认落在 `%LOCALAPPDATA%\ScoreboardConsole\data`(见下文「数据目录」)。
打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → exe 同目录的 `config.json`。
## 数据目录(重要)
electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖安装会先静默跑一遍旧版卸载器**,所以装在安装目录里的 `data\` 会被整体删掉;`%APPDATA%\Scoreboard Console` 卸载时同样会删,因此也不能用 `app.getPath('userData')`。最终方案:
| 场景 | 数据目录 |
| --- | --- |
| 打包态,`server.dataDir` 留空(默认) | `%LOCALAPPDATA%\ScoreboardConsole\data`(卸载器不碰这个位置) |
| 配置了 `server.dataDir` | 配什么用什么:绝对路径直接用;相对路径以安装目录为基准 |
| 开发态(`pnpm start`),`server.dataDir` 留空 | 不接管,沿用服务端默认(工作目录下的 `data`),仓库里的 `data/` 不受影响 |
- Console 把最终目录通过服务端的 `-data <绝对路径>` 传下去,`passwd` 子命令带同一个参数,保证改的是同一个库里的账号;
- 迁移只在**打包态**做一次:**目标目录为空(或不存在)且 `<安装目录>\data` 有数据**时,用 `fs.renameSync` 把旧目录整体改名过去(同盘秒完成)。跨盘、权限不足等任何失败都只记日志,旧数据原地保留;
- 两边都有数据时**不做任何动作**,只写一条醒目日志提示人工确认,两份都不会被自动删除;
- 迁移结果与最终数据目录都写进 `logs/console.log`(搜「数据目录」「已把旧数据从」);
- 数据目录若仍在安装目录内,启动时会额外打一条警告。
改完 `datadir.js` 跑 `node --test main/datadir.test.js`(Node 内置测试运行器,无额外依赖)。
## 行为说明
| 场景 | 行为 |
@@ -79,6 +100,7 @@ scoreboard-windows-amd64/
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
| 数据目录 | 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(安装目录之外,覆盖安装/卸载都不删),启动时按 `-data <绝对路径>` 交给服务端;打包态首次启动做一次性迁移(见「数据目录」一节),开发态不接管 |
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
@@ -86,7 +108,7 @@ scoreboard-windows-amd64/
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
| 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:<port>/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 |
| 显示器菜单 | 面板页的「大屏控制 → 显示器」卡片经 `display:list` / `display:open` / `display:close` / `display:relayout` 指挥本机开窗:按屏创建、聚焦、收窗。**全屏只在「多于一块屏 + 非主屏」时放行**,判定在主进程做,页面传 `fullscreen: true` 也会被降级为窗口化;面板页只拿到这几条白名单通道,拿不到 `ipcRenderer` |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -data <同一个数据目录> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 |
| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) |
## 配置项(`config.json`)
@@ -95,6 +117,7 @@ scoreboard-windows-amd64/
| --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 |
| `server.dataDir` | `""` | 数据目录;空串 = 用默认位置(打包态 `%LOCALAPPDATA%\ScoreboardConsole\data`),填了就按它来(相对路径以安装目录为基准)。Console 会把它作为 `-data <绝对路径>` 追加到服务端参数后 |
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) |
| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 |
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
@@ -147,4 +170,5 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试
- `powerSaveBlocker` 在长时间后台下的实际效果。
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
- 数据目录迁移实机路径:老版本安装目录里的 `data\` 是否被整体搬到 `%LOCALAPPDATA%\ScoreboardConsole\data`、跨盘安装(安装目录在 D 盘、`%LOCALAPPDATA%` 在 C 盘)时的失败日志与「旧数据原地保留」表现。
- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。
+1
View File
@@ -2,6 +2,7 @@
"server": {
"exe": "scoreboard.exe",
"args": ["-config", "config.yaml"],
"dataDir": "",
"host": "127.0.0.1",
"port": 19540,
"autoStart": true,
+9 -9
View File
@@ -18,14 +18,13 @@ function validateAccountField(value, label, required) {
}
// 空的新用户名 / 空密码直接传空串:passwd 的语义就是「留空则不改 / 随机生成」
function buildPasswdArgs({ configArg, currentUsername, newUsername, password }) {
return [
'passwd',
'-config', configArg,
'-user', currentUsername,
'-new-username', newUsername,
'-password', password
]
// dataDir 非空时带上 -data,否则 passwd 会用 config.yaml 的默认目录、
// 改到另一个库里的账号上
function buildPasswdArgs({ configArg, dataDir, currentUsername, newUsername, password }) {
const args = ['passwd', '-config', configArg]
if (typeof dataDir === 'string' && dataDir !== '') args.push('-data', dataDir)
args.push('-user', currentUsername, '-new-username', newUsername, '-password', password)
return args
}
// passwd 正常是秒级完成;卡住(等待输入等)时必须自己收尾,否则 UI 重置按钮永久禁用
@@ -36,7 +35,8 @@ function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS })
return new Promise((resolve) => {
let child
try {
// cwd 取 exe 所在目录:config.yaml 与 data/ 都跟 exe 平级
// cwd 取 exe 所在目录:config.yaml 与 rules/ 都跟 exe 平级
// (数据目录由 -data 显式指定,不依赖 cwd)
child = spawn(exePath, args, {
cwd: path.dirname(exePath),
windowsHide: true,
+4
View File
@@ -9,6 +9,10 @@ const DEFAULTS = {
server: {
exe: 'scoreboard.exe',
args: ['-config', 'config.yaml'],
// 数据目录。空串 = 用 Console 选的默认位置(打包态是
// %LOCALAPPDATA%\ScoreboardConsole\data,装在安装目录之外,覆盖安装不会丢数据);
// 填了就按它来,相对路径以安装目录为基准(详见 datadir.js)
dataDir: '',
host: '127.0.0.1',
port: 19540,
autoStart: true,
+112
View File
@@ -0,0 +1,112 @@
'use strict'
const fs = require('fs')
const path = require('path')
// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`,
// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。
// 也不能放 %APPDATA%\<AppName>(卸载器同样会删),所以默认落在 %LOCALAPPDATA%\ScoreboardConsole\data。
const LOCAL_BASE = 'ScoreboardConsole'
const DATA_SUBDIR = 'data'
// 解析最终数据目录。installDir 是安装目录(打包后 = exe 所在目录),
// 相对路径按安装目录解析,现场写 `data`、`D:\scoreboard-data` 都能用。
// passToServer=false 表示不给服务端加 -data,沿用服务端自身默认(开发态)。
function resolveDataDir({ values, installDir, env, isPackaged }) {
const base = path.resolve(installDir || '.')
const configured = values && values.server ? values.server.dataDir : ''
if (typeof configured === 'string' && configured.trim() !== '') {
const value = configured.trim()
return {
dir: path.isAbsolute(value) ? path.resolve(value) : path.resolve(base, value),
source: 'config',
passToServer: true
}
}
// 开发态不接管:继续用服务端默认(工作目录下的 data),免得仓库里的 data/ 被顶掉
if (!isPackaged) {
return { dir: path.join(base, DATA_SUBDIR), source: 'default', passToServer: false }
}
const localAppData = env ? env.LOCALAPPDATA : ''
if (!localAppData) {
// 拿不到 %LOCALAPPDATA% 时退回老位置:这次覆盖安装仍有丢数据风险,
// 但总好过把数据指向一个猜出来的路径
return { dir: path.join(base, DATA_SUBDIR), source: 'fallback', passToServer: false }
}
return {
dir: path.join(localAppData, LOCAL_BASE, DATA_SUBDIR),
source: 'localappdata',
passToServer: true
}
}
// 判断 dir 是否落在 base 之内(含 base 本身):用于提醒「数据还在安装目录里」,
// 这种配置在覆盖安装时会被卸载器连目录一起删掉
function isInside(base, dir) {
const rel = path.relative(path.resolve(base), path.resolve(dir))
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
}
// missing = 不存在,empty = 存在但空,has-data = 有内容
// 读不出来时返回 error,由调用方按「可能有数据」处理,宁可不迁移
function dirState(dir) {
let entries
try {
entries = fs.readdirSync(dir)
} catch (err) {
return err.code === 'ENOENT' ? 'missing' : 'error'
}
return entries.length > 0 ? 'has-data' : 'empty'
}
// 一次性迁移:只在「目标为空且旧目录有数据」时把旧目录整体改名过去。
// 任何一步失败都只记日志、旧数据保持原地,绝不删除数据;
// 两边都有数据时不做任何动作,只提示现场人工确认。
function migrateLegacyData({ targetDir, legacyDir, log }) {
const notify = typeof log === 'function' ? log : () => {}
if (!targetDir || !legacyDir) return { migrated: false, reason: 'invalid' }
if (path.resolve(targetDir) === path.resolve(legacyDir)) return { migrated: false, reason: 'same-dir' }
const target = dirState(targetDir)
const legacy = dirState(legacyDir)
if (target === 'error') {
notify(`警告:读不到数据目录 ${targetDir},跳过旧数据迁移;旧数据仍在 ${legacyDir}`)
return { migrated: false, reason: 'target-unreadable' }
}
if (target === 'has-data') {
if (legacy === 'has-data') {
notify(
`警告:旧数据目录 ${legacyDir} 与新数据目录 ${targetDir} 都有数据,未做迁移。` +
'请人工确认要保留哪一份(两份都不会被自动删除)'
)
return { migrated: false, reason: 'both' }
}
return { migrated: false, reason: 'target-has-data' }
}
if (legacy !== 'has-data') return { migrated: false, reason: 'no-legacy' }
try {
// Windows 上 rename 到已存在的目录会失败,先摘掉这个空目录(空目录里没有数据)
if (target === 'empty') fs.rmdirSync(targetDir)
fs.mkdirSync(path.dirname(targetDir), { recursive: true })
fs.renameSync(legacyDir, targetDir)
} catch (err) {
notify(
`警告:迁移旧数据失败(${err.message}),旧数据仍保留在 ${legacyDir};` +
`本次服务端将使用 ${targetDir},请人工把旧目录内容拷过去`
)
return { migrated: false, reason: 'failed' }
}
notify(`已把旧数据从 ${legacyDir} 迁移到 ${targetDir}`)
return { migrated: true, reason: 'moved' }
}
module.exports = { resolveDataDir, migrateLegacyData, isInside }
+191
View File
@@ -0,0 +1,191 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir')
// 用绝对路径字符串拼测试目录,避免依赖运行平台的盘符写法
const INSTALL = path.resolve('/scoreboard-install')
const LOCAL = path.resolve('/localappdata')
function makeRoot(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-datadir-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
return root
}
function writeFile(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
test('打包态默认数据目录落在 %LOCALAPPDATA%\\ScoreboardConsole\\data', () => {
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: true
})
assert.equal(info.dir, path.join(LOCAL, 'ScoreboardConsole', 'data'))
assert.equal(info.source, 'localappdata')
assert.equal(info.passToServer, true)
assert.equal(isInside(INSTALL, info.dir), false)
})
test('开发态不接管数据目录,也不给服务端加 -data', () => {
const info = resolveDataDir({
values: { server: { dataDir: '' } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: false
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.passToServer, false)
})
test('配置了绝对路径就按配置值走', () => {
const custom = path.resolve('/scoreboard-data')
const info = resolveDataDir({
values: { server: { dataDir: custom } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: true
})
assert.equal(info.dir, custom)
assert.equal(info.source, 'config')
assert.equal(info.passToServer, true)
})
test('配置了相对路径按安装目录解析', () => {
const info = resolveDataDir({
values: { server: { dataDir: 'data' } },
installDir: INSTALL,
env: { LOCALAPPDATA: LOCAL },
isPackaged: true
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.passToServer, true)
assert.equal(isInside(INSTALL, info.dir), true)
})
test('缺 %LOCALAPPDATA% 时退回安装目录,不下发 -data', () => {
const info = resolveDataDir({
values: { server: {} },
installDir: INSTALL,
env: {},
isPackaged: true
})
assert.equal(info.dir, path.join(INSTALL, 'data'))
assert.equal(info.source, 'fallback')
assert.equal(info.passToServer, false)
})
test('目标为空且旧目录有数据 → 整体迁移', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'db')
writeFile(path.join(legacyDir, 'event_projs', 'default', 'event.db'), 'event')
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, true)
assert.equal(result.reason, 'moved')
assert.equal(fs.existsSync(legacyDir), false)
assert.equal(fs.readFileSync(path.join(targetDir, 'system.db'), 'utf8'), 'db')
assert.equal(fs.existsSync(path.join(targetDir, 'event_projs', 'default', 'event.db')), true)
assert.match(lines.join('\n'), /已把旧数据从 .* 迁移到 /)
})
test('目标目录已存在但是空目录 → 照样迁移', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'db')
fs.mkdirSync(targetDir, { recursive: true })
const result = migrateLegacyData({ targetDir, legacyDir, log: () => {} })
assert.equal(result.migrated, true)
assert.equal(fs.readFileSync(path.join(targetDir, 'system.db'), 'utf8'), 'db')
assert.equal(fs.existsSync(legacyDir), false)
})
test('两边都有数据 → 不动任何一边,只记日志', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(root, 'localappdata', 'ScoreboardConsole', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'old')
writeFile(path.join(targetDir, 'system.db'), 'new')
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, false)
assert.equal(result.reason, 'both')
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'old')
assert.equal(fs.readFileSync(path.join(targetDir, 'system.db'), 'utf8'), 'new')
assert.match(lines.join('\n'), /都有数据/)
})
test('旧目录没有数据 / 目标已有数据 / 两边同路径 → 都不动作', (t) => {
const root = makeRoot(t)
const emptyLegacy = path.join(root, 'a', 'data')
fs.mkdirSync(emptyLegacy, { recursive: true })
assert.equal(
migrateLegacyData({ targetDir: path.join(root, 'b', 'data'), legacyDir: emptyLegacy, log: () => {} }).reason,
'no-legacy'
)
assert.equal(
migrateLegacyData({ targetDir: path.join(root, 'c', 'data'), legacyDir: path.join(root, 'd', 'data'), log: () => {} }).reason,
'no-legacy'
)
const legacyDir = path.join(root, 'e', 'data')
const targetDir = path.join(root, 'f', 'data')
writeFile(path.join(targetDir, 'system.db'), 'new')
assert.equal(migrateLegacyData({ targetDir, legacyDir, log: () => {} }).reason, 'target-has-data')
assert.equal(migrateLegacyData({ targetDir, legacyDir: targetDir, log: () => {} }).reason, 'same-dir')
})
test('迁移失败时旧数据原地不动', (t) => {
const root = makeRoot(t)
// 目标目录嵌在旧目录内部,rename 必然失败(EINVAL)
const legacyDir = path.join(root, 'install', 'data')
const targetDir = path.join(legacyDir, 'nested')
writeFile(path.join(legacyDir, 'system.db'), 'db')
const lines = []
const result = migrateLegacyData({ targetDir, legacyDir, log: (line) => lines.push(line) })
assert.equal(result.migrated, false)
assert.equal(result.reason, 'failed')
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'db')
assert.match(lines.join('\n'), /旧数据仍保留在/)
})
test('目标路径读不出来时按「可能有数据」处理,不做迁移', (t) => {
const root = makeRoot(t)
const legacyDir = path.join(root, 'install', 'data')
writeFile(path.join(legacyDir, 'system.db'), 'db')
// 目标目录的上级是个文件,readdir 会报 ENOTDIR
const blocker = path.join(root, 'blocker')
writeFile(blocker, 'not a dir')
const lines = []
const result = migrateLegacyData({
targetDir: path.join(blocker, 'data'),
legacyDir,
log: (line) => lines.push(line)
})
assert.equal(result.migrated, false)
assert.equal(result.reason, 'target-unreadable')
assert.equal(fs.readFileSync(path.join(legacyDir, 'system.db'), 'utf8'), 'db')
assert.match(lines.join('\n'), /跳过旧数据迁移/)
})
+39 -3
View File
@@ -13,6 +13,7 @@ const {
} = require('electron')
const { loadConfig, updateConfig, resolveExecutable } = require('./config')
const { resolveDataDir, migrateLegacyData, isInside } = require('./datadir')
const { LogBus } = require('./log-bus')
const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays')
@@ -40,6 +41,8 @@ function main() {
let quitting = false
let shutdownDone = false
let exePath = null
// 解析后的数据目录信息(见 datadir.js),拉起服务端和 passwd 都用它
let dataDirInfo = null
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
let instancesTimer = null
let instancesLastReason = null
@@ -431,6 +434,11 @@ function main() {
return 'config.yaml'
}
// 数据目录同理:不带上同一个 -data,passwd 会改到另一个库里的账号上
function passwdDataArg() {
return dataDirInfo && dataDirInfo.passToServer ? dataDirInfo.dir : ''
}
// 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP,
// 所以不需要任何登录态,这就是面板能「免验证」的原因。
async function resetAdminAccount(event, payload) {
@@ -454,14 +462,16 @@ function main() {
}
const configArg = passwdConfigArg()
const args = buildPasswdArgs({ configArg, currentUsername, newUsername, password })
const dataDirArg = passwdDataArg()
const args = buildPasswdArgs({ configArg, dataDir: dataDirArg, currentUsername, newUsername, password })
const sender = event.sender
accountBusy = true
// 日志里不能带明文密码
log.push(
'console',
`重置管理员账号:${exe} passwd -config ${configArg} -user ${currentUsername}` +
`重置管理员账号:${exe} passwd -config ${configArg}` +
`${dataDirArg ? ` -data ${dataDirArg}` : ''} -user ${currentUsername}` +
`${newUsername ? ` -new-username ${newUsername}` : ''}(密码已隐藏)`
)
@@ -565,10 +575,36 @@ function main() {
exePath = resolved.path
log.push('console', `服务端程序:${resolved.path}`)
const installDir = path.dirname(resolved.path)
dataDirInfo = resolveDataDir({
values: cfg.values,
installDir,
env: process.env,
isPackaged: app.isPackaged
})
// 老版本把数据放在安装目录里,而覆盖安装会先静默跑旧卸载器把安装目录整个删掉,
// 所以先做一次性迁移。只在打包态做,开发态不动仓库里的 data/。
if (app.isPackaged && dataDirInfo.passToServer) {
migrateLegacyData({
targetDir: dataDirInfo.dir,
legacyDir: path.join(installDir, 'data'),
log: (message) => log.push('console', message)
})
}
log.push(
'console',
`数据目录:${dataDirInfo.dir}${dataDirInfo.passToServer ? '' : '(沿用服务端默认)'}`
)
if (dataDirInfo.passToServer && isInside(installDir, dataDirInfo.dir)) {
log.push('console', `警告:数据目录仍在安装目录内(${dataDirInfo.dir}),覆盖安装时会被删除`)
}
service = new ServiceSupervisor({
values: cfg.values,
exePath: resolved.path,
log
log,
dataDir: dataDirInfo.passToServer ? dataDirInfo.dir : null
})
displays = new DisplayManager({
+11 -4
View File
@@ -37,12 +37,14 @@ function healthHost(server) {
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
constructor({ values, exePath, log }) {
constructor({ values, exePath, log, dataDir }) {
super()
this.values = values
this.server = values.server
this.exePath = exePath
this.log = log
// 绝对路径,非空时下发给服务端 -data;为空表示沿用服务端默认数据目录
this.dataDir = dataDir || null
this.child = null
this.state = 'stopped'
@@ -111,7 +113,9 @@ class ServiceSupervisor extends EventEmitter {
}
const args = Array.isArray(this.server.args) ? this.server.args : []
// 工作目录取 exe 所在目录:部署包里 config.yaml / data/ 都跟 exe 平级
// 显式下发数据目录:默认位置在安装目录之外,覆盖安装时才不会被卸载器连目录删掉
const spawnArgs = this.dataDir ? [...args, '-data', this.dataDir] : args
// 工作目录取 exe 所在目录:部署包里 config.yaml / rules/ 都跟 exe 平级
const cwd = path.dirname(this.exePath)
this.lastError = null
@@ -120,7 +124,10 @@ class ServiceSupervisor extends EventEmitter {
this.forcedKill = false
this.startedAt = new Date().toISOString()
this.setState('starting')
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
this.log.push(
'console',
`启动服务端:${this.exePath} ${spawnArgs.join(' ')}(cwd=${cwd},数据目录=${this.dataDir || '服务端默认'})`
)
// 每次拉起都换一把新 token,只通过环境变量交给这一个子进程,
// 停止时凭它调用服务端的 /api/shutdown
@@ -128,7 +135,7 @@ class ServiceSupervisor extends EventEmitter {
let child
try {
child = spawn(this.exePath, args, {
child = spawn(this.exePath, spawnArgs, {
cwd,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
+15 -8
View File
@@ -4,10 +4,14 @@ REM Scoreboard one-click backup (Windows)
REM
REM Calls scoreboard.exe's backup subcommand (VACUUM INTO), so it is safe to
REM run while the service is up and does not need an external sqlite3.exe.
REM Output goes to data\backups\; the server keeps the newest 20 per DB
REM Output goes to <data dir>\backups\; the server keeps the newest 20 per DB
REM (system / each event).
REM The data dir is the one the Console uses, i.e. %LOCALAPPDATA%\ScoreboardConsole\data
REM (falling back to <root>\data when that does not exist yet). It lives outside
REM the install dir on purpose: reinstalling over an existing install wipes the
REM install dir, so data must not be kept there.
REM NOTE: only .db snapshots are taken. Background images / emblems under
REM data\event_projs\<project>\uploads\ are NOT included; copy that
REM <data dir>\event_projs\<project>\uploads\ are NOT included; copy that
REM directory separately if you need them on the archive.
REM
REM Usage: deploy\backup.bat manual backup (reason=manual)
@@ -21,7 +25,9 @@ chcp 65001 >nul 2>&1
cd /d "%~dp0.."
set "ROOT=%CD%"
REM Same data dir the Console hands to the server via -data.
set "DATA=%ROOT%\data"
if defined LOCALAPPDATA if exist "%LOCALAPPDATA%\ScoreboardConsole\data" set "DATA=%LOCALAPPDATA%\ScoreboardConsole\data"
set "REASON=manual"
if not "%~1"=="" set "REASON=%~1"
@@ -32,7 +38,8 @@ set "REASON=%REASON:"=%"
if not exist "%DATA%" (
echo [ERROR] data directory not found: %DATA%
echo Run this script from the project root.
echo Start Scoreboard_Console.exe once so the service creates it,
echo or point this script at the data dir via -data manually.
exit /b 1
)
@@ -48,10 +55,10 @@ if not defined EXE (
exit /b 1
)
REM dataDir is relative and resolved against the current directory by the server,
REM so this script must run from the project root.
REM config.yaml is still needed for port / backup settings; -data below picks the
REM data dir explicitly, so the current directory no longer decides it.
if not exist "%ROOT%\config.yaml" (
echo [ERROR] %ROOT%\config.yaml not found, cannot locate dataDir.
echo [ERROR] %ROOT%\config.yaml not found.
exit /b 1
)
@@ -70,12 +77,12 @@ set "FOUND="
for /d %%D in ("%DATA%\event_projs\*") do (
set "FOUND=1"
echo backup event %%~nxD
"%EXE%" backup -config config.yaml -project "%%~nxD" -reason "%REASON%"
"%EXE%" backup -config config.yaml -project "%%~nxD" -data "%DATA%" -reason "%REASON%"
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
)
if not defined FOUND (
echo backup default event
"%EXE%" backup -config config.yaml -reason "%REASON%"
"%EXE%" backup -config config.yaml -data "%DATA%" -reason "%REASON%"
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
)
+8 -6
View File
@@ -16,14 +16,15 @@
- [ ] Windows 10/11 x64,接市电(建议接 UPS)
- [ ] 端口 **19540** 未被占用:`netstat -ano | findstr 19540`
- [ ] 数据目录可写:默认 `<安装目录>\data\`,需要放行杀毒/防火墙
- [ ] 数据目录可写:默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(在安装目录之外,升级/卸载都不会删),需要放行杀毒/防火墙
- [ ] 大屏通过 HDMI 接在**同一台机器**上(系统显示模式设为「扩展」)
- [ ] **安装目录不要选 `C:\Program Files`**(那里普通用户没写权限,数据库会写不进去);装到 D 盘等普通目录即可,向导里可以改路径
- [ ] **安装目录不要选 `C:\Program Files`**(那里普通用户没写权限,日志会写不进去);装到 D 盘等普通目录即可,向导里可以改路径
## 3. 首次启动
- [ ] 双击 `Scoreboard_Console.exe`,启动管理窗口出现
- [ ] 服务端状态变为「运行中 · 就绪」
- [ ] 「运行日志」里 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(**不是**安装目录),且没有「都有数据 / 迁移失败」的警告
- [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录
- [ ] **管理员初始密码**在 `logs\console.log` 里搜 `INITIAL ADMIN CREATED`,密码就在该行上方;也可以直接打开 Console 的「运行日志」面板往上翻。首次登录会要求改密
@@ -45,9 +46,9 @@
## 6. 数据与备份
- [ ] 确认 `data\backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟)
- [ ] 确认数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 下的 `backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟)
- [ ] 开幕前手动点一次「立即备份」
- [ ] 记下备份目录位置,必要时整个目录拷到 U 盘
- [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<项目名>\uploads\` 里还有上传的图片)拷一份到 U 盘
## 7. 现场流程演练(建议至少一次)
@@ -61,8 +62,9 @@
| 故障 | 动作 |
| --- | --- |
| 服务端无响应 | 服务管理窗口「重启服务」 |
| 主机死机 | 备机启动同一份部署包,拷回 `data\` 最近备份 |
| 数据库损坏 | 停机,用 `data\backups\` 最新备份替换 `data\event_projs\<项目名>\event.db`(先删 `-wal`/`-shm`) |
| 主机死机 | 备机启动同一份部署包,把数据目录(`%LOCALAPPDATA%\ScoreboardConsole\data`)拷回去 |
| 数据库损坏 | 停机,用数据目录下 `backups\` 的最新备份替换 `event_projs\<项目名>\event.db`(先删 `-wal`/`-shm`),详见部署指南第 8.3 节 |
| 大屏黑屏 | 服务管理窗口「重新排布」;仍不行就用备用笔记本开同一地址 |
| 覆盖安装后数据不见了 | 看「运行日志」里的 `数据目录:` 与迁移警告,按部署指南第 9.9 节处理 |
> 完整恢复步骤见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
+1 -1
View File
@@ -515,7 +515,7 @@
当前服务端一次只运行一个赛事项目(默认 `default`)。导入赛事项目包后,需要重启服务端并指定 `-project <项目名>` 才能切换,详见部署指南第 8.5 节。
**Q:导出的文件在哪里?**
结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在 `data\exports\` 下生成临时包(导出完成后自动删除)。
结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在数据目录下的 `exports\` 里生成临时包(导出完成后自动删除)。数据目录默认是 `%LOCALAPPDATA%\ScoreboardConsole\data`,详见部署指南第 3.4 节。
**Q:登录多久会过期?**
会话有效期 12 小时。过期后自动回到只读访客视图,重新登录即可。
+82 -17
View File
@@ -13,7 +13,7 @@
| 网络 | 完全离线即可运行。若需要手机/平板/电视访问,用独立交换机或路由器组成局域网 |
| 磁盘 | 建议预留 ≥ 10GB |
| 运行时 | 无需安装 Go / Node / 数据库等任何运行时,前端已内嵌进可执行文件 |
| 权限 | 首次运行会在部署目录下创建 `data\`、`logs\`,请确保该目录可写 |
| 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\data` 下建库、在部署目录的 `logs\` 写日志,请确保这两处可写 |
> 服务端只构建 Windows amd64 一个版本。Linux / 其他平台不在交付范围内。
@@ -51,7 +51,7 @@ deploy\build.bat
3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。
4. 双击「赛事积分榜」启动。
> 程序会在自己的安装目录下创建 `data\` 与 `logs\`,请安装到当前用户有写权限的目录;若装到受保护的系统目录,可能因无写入权限而无法建库。
> 赛事数据存放在 `%LOCALAPPDATA%\ScoreboardConsole\data`(不随安装目录走,见第 8.1 节),日志写在安装目录的 `logs\` 下;安装目录本身只要当前用户可写即可。
### 3.2 使用免安装包
@@ -61,7 +61,7 @@ deploy\build.bat
### 3.3 首次启动会自动完成
- 拉起并守护服务端 `scoreboard.exe`;
- 在 `data\` 下自动建库、建目录;
- 在数据目录(默认 `%LOCALAPPDATA%\ScoreboardConsole\data`)下自动建库、建目录;
- 打开服务端管理面板窗口;
- 把大屏页面投到扩展屏;
- 启用防熄屏;
@@ -77,11 +77,32 @@ deploy\build.bat
├── scoreboard.exe # Go 服务端
├── config.yaml # 服务端配置
├── scoreboard.png # 应用图标
├── data\ # 首次运行生成:数据库、备份
└── logs\console.log # 首次运行生成:运行日志
```
> `data\` 与二进制分离,升级时替换可执行文件即可保留数据。
数据目录不在安装目录里,是独立的一处(详见 3.4):
```
%LOCALAPPDATA%\ScoreboardConsole\data\ # 数据库、备份、上传的图片
```
### 3.4 数据放在哪里(升级/卸载都不会删)
覆盖安装时安装程序会**先静默运行旧版卸载器**,而卸载器会把整个安装目录删掉,所以数据库、规则、用户、上传的图片都不能放在安装目录里。现在的规则:
| 场景 | 数据目录 |
| --- | --- |
| 默认(`console\config.json` 的 `server.dataDir` 留空) | `%LOCALAPPDATA%\ScoreboardConsole\data`,即 `C:\Users\<用户名>\AppData\Local\ScoreboardConsole\data` |
| 想放到别处,例如 D 盘 | 在 `console\config.json` 里把 `server.dataDir` 改成 `"D:\\scoreboard-data"`(绝对路径),或相对安装目录的路径 |
| 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** |
- Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行;
- **一次性迁移**:如果新数据目录还是空的,而旧的 `<安装目录>\data` 里有数据,Console 会在启动时把旧目录**整体改名**过去(同盘很快),日志里会写「已把旧数据从 X 迁移到 Y」。任何失败(跨盘、权限不足等)都只记日志,旧数据原地保留,不会被删;
- 如果两个目录里**都有数据**,Console 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份;
- 迁移只在安装包版本(打包后)执行一次,`pnpm start` 之类的开发运行不会碰仓库里的 `data\`;
- 迁移的**旧目录只认 `<安装目录>\data`**。如果你已经把数据迁到默认位置、之后又想改成 D 盘等其他目录,Console **不会**自动搬运:请先停服务,把 `%LOCALAPPDATA%\ScoreboardConsole\data` 整个目录复制到新路径,再改 `server.dataDir` 并重启 Console。
> 升级到本版本后第一次启动,请打开 Console 的「运行日志」确认是否有迁移记录,并核对管理端里的赛事/账号数据是否完整。
---
@@ -167,9 +188,11 @@ findstr /C:"INITIAL ADMIN CREATED" logs\console.log
在 Console 里先**停止服务**,然后在部署目录(`config.yaml` 所在目录)执行:
```bat
scoreboard.exe passwd -config config.yaml
scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsole\data"
```
> **`-data` 不能省**:不加它服务端会去用工作目录下的 `data\`,改的是另一个库里的账号(那个库是空的,看起来像「密码重置成功但登录不上」)。数据目录改过配置的,把这里换成 `console\config.json` 里 `server.dataDir` 实际生效的那个路径;实在不确定,可以先看 `logs\console.log` 里 `数据目录:` 那一行。
该命令会为用户 `admin` 生成一个新的随机密码并打印出来,同时要求下次登录强制改密。可选参数:
| 参数 | 说明 |
@@ -177,6 +200,7 @@ scoreboard.exe passwd -config config.yaml
| `-user <用户名>` | 指定要重置的用户,默认 `admin` |
| `-password <新密码>` | 直接指定新密码;留空则随机生成并打印 |
| `-new-username <新用户名>` | 同时修改用户名 |
| `-data <数据目录>` | 数据目录;通过 Console 启动时必须带上同一个值(见第 3.4 节) |
---
@@ -188,7 +212,7 @@ scoreboard.exe passwd -config config.yaml
| --- | --- | --- |
| `server.host` | `0.0.0.0` | 监听地址;`0.0.0.0` 表示局域网内其他设备也能访问 |
| `server.port` | `19540` | HTTP 端口 |
| `dataDir` | `data` | 数据目录,相对服务端工作目录解析 |
| `dataDir` | `data` | 数据目录,相对服务端工作目录解析;**Console 启动时会用 `-data <绝对路径>` 覆盖它**,配置项本身只对直接运行 `scoreboard.exe` 时生效 |
| `log.level` | `info` | 日志级别:`debug` / `info` / `warn` / `error` |
| `backup.intervalMinutes` | `5` | 自动备份间隔(分钟),`<=0` 关闭自动备份 |
| `backup.keep` | `20` | 每个库滚动保留的备份份数 |
@@ -200,6 +224,7 @@ scoreboard.exe passwd -config config.yaml
| --- | --- | --- |
| `server.exe` | `scoreboard.exe` | 服务端可执行文件;相对路径会在配置目录、当前目录、上级目录、`dist\` 依次查找 |
| `server.args` | `["-config","config.yaml"]` | 传给服务端的启动参数 |
| `server.dataDir` | `""` | 数据目录;留空 = 用默认位置 `%LOCALAPPDATA%\ScoreboardConsole\data`。填了就按它来:绝对路径直接用,相对路径以安装目录为基准。Console 会把它作为 `-data <绝对路径>` 追加到启动参数后(见第 3.4 节) |
| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查与打开管理面板用的地址;配成 `0.0.0.0` 时本机探测自动改走回环地址 |
| `server.autoStart` | `true` | Console 启动后是否自动拉起服务端 |
| `server.healthIntervalMs` | `5000` | 健康检查间隔(毫秒) |
@@ -295,7 +320,13 @@ http://<主机IP>:19540/display
### 8.1 备份目录
所有备份都在 `data\backups\` 下,文件名为 `.db` 快照。该目录不进版本库。
所有备份都在**数据目录**下的 `backups\` 里,文件名为 `.db` 快照。默认数据目录是 `%LOCALAPPDATA%\ScoreboardConsole\data`,所以备份在:
```
%LOCALAPPDATA%\ScoreboardConsole\data\backups\
```
数据目录改过 `server.dataDir` 的,按改后的路径找(同目录下的 `backups\`)。该目录不进版本库。
### 8.2 如何备份
@@ -303,13 +334,14 @@ http://<主机IP>:19540/display
| --- | --- | --- |
| 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.db` |
| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsole\data"` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`) |
| 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` |
- 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`;
- 自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算,超出的旧备份自动删除;
- 同一时刻只允许一个备份在进行,自动备份与手动、发布前备份不会互相踩;
- `deploy\backup.bat` 会自动用和 Console 相同的数据目录(优先 `%LOCALAPPDATA%\ScoreboardConsole\data`,该目录还不存在时回退到脚本上级目录的 `data`),并把该目录作为 `-data` 传给服务端,所以从哪里运行都不会备份到别的库;
- 现场建议:开幕前、每次发布高峰前、每天结束时各手动备份一次(自动备份只是兜底)。
### 8.3 如何恢复
@@ -317,18 +349,22 @@ http://<主机IP>:19540/display
服务端目前**没有一键恢复接口**,恢复需停机手工执行:
1. 在 Console 里**停止服务**,避免恢复过程中继续写入;
2. 把当前的 `data\event_projs\<项目名>\event.db` 改名留档,确认恢复失败还能退回;
2. 把当前的 `<数据目录>\event_projs\<项目名>\event.db` 改名留档,确认恢复失败还能退回;
3. 选一份目标备份复制为 `event.db`(覆盖原文件);
4. 同时删除同目录下的 `event.db-wal` 与 `event.db-shm`;
5. 重新启动服务,核对积分榜与最近一次发布记录。
> `data\system.db` 保存账号与会话信息,一般不需要回退;如需一并回退,替换它的方法与 `event.db` 相同(同时删除 `system.db-wal` / `system.db-shm`)。
> `<数据目录>\system.db` 保存账号与会话信息,一般不需要回退;如需一并回退,替换它的方法与 `event.db` 相同(同时删除 `system.db-wal` / `system.db-shm`)。
>
> 恢复会丢弃备份时间点之后的所有改动。现场若还有备机,优先切换备机比原地恢复更快。
### 8.4 整机迁移
把整个部署目录(含 `data\`)拷贝到另一台同版本 Windows 机器,启动 Console 即可,无需重新配置。
把 **安装目录**(`Scoreboard_Console.exe`、`config.json`、`config.yaml`、`rules\` 等)与**数据目录** `%LOCALAPPDATA%\ScoreboardConsole\data` 一起拷到另一台同版本 Windows 机器:
- 安装目录:按同样路径放好,或直接用新机器上的安装包装一遍;
- 数据目录:拷到新机器的 `%LOCALAPPDATA%\ScoreboardConsole\data`(同一用户名下路径一致,最省事);
- 启动 Console 即可,无需重新配置;若数据目录放到了别处,记得同步改 `console\config.json` 的 `server.dataDir`。
### 8.5 赛事项目包导出 / 导入
@@ -337,10 +373,23 @@ http://<主机IP>:19540/display
| 导出 | 「导出 → 赛事项目包 → 导出 zip」 | 包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`;整包拷走即可在另一台机器复现 |
| 导入 | 「导出 → 赛事项目包 → 选择 .zip → 导入」 | **仅管理员**可操作;可填「重命名」,勾选「覆盖同名项目」会先二次确认 |
- 导入后项目落在 `data\event_projs\<项目名>\`;
- 当前运行的服务端默认使用 `default` 项目。要让新导入的赛事生效,可在 Console 的 `config.json` 里把 `server.args` 改为 `["-config","config.yaml","-project","<项目名>"]`,然后重启 Console;也可以直接用 `scoreboard.exe -config config.yaml -project <项目名>` 启动;
- 导入后项目落在 `<数据目录>\event_projs\<项目名>\`;
- 当前运行的服务端默认使用 `default` 项目。要让新导入的赛事生效,可在 Console 的 `config.json` 里把 `server.args` 改为 `["-config","config.yaml","-project","<项目名>"]`,然后重启 Console;也可以直接用 `scoreboard.exe -config config.yaml -project <项目名> -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 启动;
- 导入期间请勿停止服务端进程。
### 8.6 手工备份 / 迁移整个数据目录
快照备份之外,数据库文件、上传的图片、备份都可以整体拷走:
1. 在 Console 里**停止服务**(托盘 → 停止服务),避免拷贝过程中继续写入;
2. 把整个数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 复制到 U 盘或其他盘(里面含 `system.db`、`event_projs\`(数据库 + `uploads\` 图片)、`backups\`);
3. 恢复时把整个目录拷回去,然后:
- 拷回**原路径**:直接启动 Console 即可;
- 拷到**别的路径**:先建好目标目录并放入数据,再在 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console;
4. 恢复完成后在管理端核对赛事、账号、上传图片是否齐全。
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标在 `event_projs\<项目名>\uploads\` 里,不拷这个目录会丢图片。
---
## 9. 故障排查
@@ -386,7 +435,7 @@ http://<主机IP>:19540/display
### 9.4 服务反复重启
- 打开 `logs\console.log`,搜索 `[server!]`,这是服务端错误输出;
- 检查 `data\` 是否可写、磁盘是否已满;
- 检查数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 是否可写、磁盘是否已满;
- 检查 `config.yaml` 语法与端口是否合法。
### 9.5 页面显示「示例数据」
@@ -410,14 +459,30 @@ http://<主机IP>:19540/display
### 9.8 忘记管理员密码
见第 5.5 节,使用 `scoreboard.exe passwd -config config.yaml` 重置。
见第 5.5 节,使用 `scoreboard.exe passwd -config config.yaml -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 重置。
### 9.9 升级后数据不见了 / 日志里有迁移警告
覆盖安装时安装程序会先跑旧卸载器删掉安装目录,所以本版本起数据默认放在 `%LOCALAPPDATA%\ScoreboardConsole\data`。首次启动后先看 Console「运行日志」或 `logs\console.log`:
| 日志 | 含义 |
| --- | --- |
| `数据目录:<路径>` | 本次实际使用的数据目录,先确认它是不是你期望那个 |
| `已把旧数据从 X 迁移到 Y` | 旧数据已整体搬过去,属正常 |
| `警告:...都有数据...` | 两个目录都有数据,Console 没有动任何一边;请人工把要保留的那份拷到 `数据目录:` 指向的位置 |
| `警告:迁移旧数据失败...` | 跨盘或权限问题;旧数据仍在日志里写的 `X`,手工拷过去再重启即可 |
| `警告:数据目录仍在安装目录内` | `server.dataDir` 配置成了安装目录里的路径,覆盖安装会丢数据,请改到安装目录之外 |
若安装目录里的 `data\` 已被删掉,优先用 `backups\` 里最新的 `.db` 快照按第 8.3 节恢复。
---
## 10. 上线检查清单
- [ ] 服务端开机自启、崩溃自动拉起已验证
- [ ] `data\backups\` 可写,已生成至少一份备份
- [ ] 「运行日志」里的 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(不是安装目录),且没有迁移警告
- [ ] 数据目录的 `backups\` 可写,已生成至少一份备份
- [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片)
- [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常
- [ ] 管理端各角色账号可登录,权限正确
- [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台
+1 -1
View File
@@ -3,6 +3,7 @@ module git.yeij.top/AskaEth/Scoreboard/server
go 1.26.0
require (
github.com/coder/websocket v1.8.15
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.57.0
gopkg.in/yaml.v3 v3.0.1
@@ -10,7 +11,6 @@ require (
)
require (
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
+11
View File
@@ -71,6 +71,17 @@ func Load(path string) (*Config, error) {
return cfg, nil
}
// ApplyDataDir 用命令行显式指定的数据目录覆盖配置里的 dataDir:dir 为空或只有空白时保持原样。
// 返回是否发生了覆盖,调用方据此记录数据目录来源。
func (c *Config) ApplyDataDir(dir string) bool {
dir = strings.TrimSpace(dir)
if dir == "" {
return false
}
c.DataDir = dir
return true
}
func (c *Config) validate() error {
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("server.port 非法: %d", c.Server.Port)
+34
View File
@@ -29,3 +29,37 @@ func TestLoadAllowedOrigins(t *testing.T) {
t.Fatalf("白名单解析错误: got %v want %v", cfg.Server.AllowedOrigins, want)
}
}
// -data 只在非空时覆盖配置:不传与传空串都必须保持配置文件里的值,
// 否则 Console 之外的便携/手工运行会被改写数据目录。
func TestApplyDataDir(t *testing.T) {
cases := []struct {
name string
dir string
want string
applied bool
}{
{"非空覆盖配置值", `/var/lib/scoreboard`, `/var/lib/scoreboard`, true},
{"两侧空白会被去掉", " /var/lib/scoreboard ", `/var/lib/scoreboard`, true},
{"空串保持配置值", "", "cfg-data", false},
{"纯空白等于不传", " ", "cfg-data", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("dataDir: cfg-data\nserver:\n port: 19540\n"), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
if got := cfg.ApplyDataDir(tc.dir); got != tc.applied {
t.Fatalf("ApplyDataDir(%q) 覆盖标记: got %v want %v", tc.dir, got, tc.applied)
}
if cfg.DataDir != tc.want {
t.Fatalf("ApplyDataDir(%q) 后 DataDir: got %q want %q", tc.dir, cfg.DataDir, tc.want)
}
})
}
}
+11
View File
@@ -48,6 +48,7 @@ func main() {
configPath := flag.String("config", "config.yaml", "配置文件路径")
project := flag.String("project", "default", "赛事项目名(data/event_projs/<项目名>/event.db)")
dataDir := flag.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
flag.Parse()
cfg, err := config.Load(*configPath)
@@ -56,8 +57,15 @@ func main() {
os.Exit(1)
}
// 必须在 store.Open 等所有使用 cfg.DataDir 的地方之前覆盖:Console 靠它把数据放到安装目录之外。
// 不传(或传空串)时保持配置文件里的相对 data,便携与手工运行的行为不变。
dataDirFromFlag := cfg.ApplyDataDir(*dataDir)
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.SlogLevel()}))
slog.SetDefault(logger)
if dataDirFromFlag {
logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir)
}
st, err := store.Open(cfg.DataDir)
if err != nil {
@@ -179,6 +187,7 @@ func runBackup(args []string) error {
configPath := fs.String("config", "config.yaml", "配置文件路径")
project := fs.String("project", "default", "赛事项目名")
reason := fs.String("reason", "manual", "备份原因:manual / publish / auto")
dataDir := fs.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
if err := fs.Parse(args); err != nil {
return err
}
@@ -187,6 +196,8 @@ func runBackup(args []string) error {
if err != nil {
return err
}
// 与服务端主命令同一语义:备份的必须是 Console 实际在用的那个数据目录。
cfg.ApplyDataDir(*dataDir)
dir := filepath.Join(cfg.DataDir, "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
+66 -13
View File
@@ -12,19 +12,8 @@ import (
func TestRunBackupUsesEventPrefix(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
projDir := filepath.Join(dataDir, "event_projs", "default")
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
// runBackup 只在 event.db 存在时备份赛事库;空库也走同一条 VACUUM INTO 路径。
if err := os.WriteFile(filepath.Join(projDir, "event.db"), nil, 0o644); err != nil {
t.Fatalf("创建赛事库: %v", err)
}
cfgPath := filepath.Join(root, "config.yaml")
cfg := "dataDir: " + dataDir + "\nserver:\n port: 19540\n"
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("写配置: %v", err)
}
writeEventDB(t, dataDir)
cfgPath := writeConfig(t, root, dataDir)
if err := runBackup([]string{"-config", cfgPath, "-project", "default", "-reason", "manual"}); err != nil {
t.Fatalf("runBackup: %v", err)
@@ -48,6 +37,70 @@ func TestRunBackupUsesEventPrefix(t *testing.T) {
}
}
// 数据目录搬到安装目录之外后,CLI 备份必须跟 -data 走,否则备份的是旧目录(甚至是空目录)。
func TestRunBackupDataFlagOverridesConfig(t *testing.T) {
root := t.TempDir()
cfgDataDir := filepath.Join(root, "cfg-data")
flagDataDir := filepath.Join(root, "flag-data")
writeEventDB(t, cfgDataDir)
writeEventDB(t, flagDataDir)
cfgPath := writeConfig(t, root, cfgDataDir)
if err := runBackup([]string{"-config", cfgPath, "-project", "default", "-reason", "manual", "-data", flagDataDir}); err != nil {
t.Fatalf("runBackup: %v", err)
}
entries, err := os.ReadDir(filepath.Join(flagDataDir, "backups"))
if err != nil {
t.Fatalf("读取 -data 指向的备份目录: %v", err)
}
if len(entries) == 0 {
t.Fatalf("-data 指向的目录里没有备份文件")
}
if _, err := os.Stat(filepath.Join(cfgDataDir, "backups")); !os.IsNotExist(err) {
t.Fatalf("配置里的数据目录不应被写入: %v", err)
}
}
// -data 传空串等于不传:仍然用配置文件里的数据目录,便携与手工运行的行为不变。
func TestRunBackupEmptyDataFlagKeepsConfig(t *testing.T) {
root := t.TempDir()
cfgDataDir := filepath.Join(root, "cfg-data")
writeEventDB(t, cfgDataDir)
cfgPath := writeConfig(t, root, cfgDataDir)
if err := runBackup([]string{"-config", cfgPath, "-project", "default", "-reason", "manual", "-data", ""}); err != nil {
t.Fatalf("runBackup: %v", err)
}
if _, err := os.Stat(filepath.Join(cfgDataDir, "backups")); err != nil {
t.Fatalf("空 -data 时应写配置里的数据目录: %v", err)
}
}
// writeEventDB 造一个空的赛事库;runBackup 只在 event.db 存在时备份赛事库,空库也走同一条 VACUUM INTO 路径。
func writeEventDB(t *testing.T, dataDir string) {
t.Helper()
projDir := filepath.Join(dataDir, "event_projs", "default")
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
if err := os.WriteFile(filepath.Join(projDir, "event.db"), nil, 0o644); err != nil {
t.Fatalf("创建赛事库: %v", err)
}
}
// writeConfig 写一份只指定数据目录的最小配置,返回配置路径。
func writeConfig(t *testing.T, dir, dataDir string) string {
t.Helper()
path := filepath.Join(dir, "config.yaml")
raw := "dataDir: " + dataDir + "\nserver:\n port: 19540\n"
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("写配置: %v", err)
}
return path
}
// 现场按文档用 findstr 搜纯 ASCII 锚找初始密码;中文匹配在 chcp 936/65001 下不可靠。
func TestInitialAdminBannerAsciiAnchor(t *testing.T) {
const anchor = "=== INITIAL ADMIN CREATED (password above) ==="
+4
View File
@@ -15,6 +15,7 @@ import (
func runPasswd(args []string) error {
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
configPath := fs.String("config", "config.yaml", "配置文件路径")
dataDir := fs.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
username := fs.String("user", "admin", "用户名")
newUsername := fs.String("new-username", "", "同时把用户名改成这个(留空则不改)")
password := fs.String("password", "", "新密码;留空则随机生成并打印")
@@ -26,6 +27,9 @@ func runPasswd(args []string) error {
if err != nil {
return err
}
// Console 拉起服务端时用 -data 把数据指到安装目录之外;这里必须用同一份,
// 否则改的是安装目录下那个空库里的账号。
cfg.ApplyDataDir(*dataDir)
st, err := store.Open(cfg.DataDir)
if err != nil {