From 005b51b2ecbd6a856bc107232c4f8b1fcdc18502 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 09:17:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=A7=BB=E5=87=BA=E5=AE=89=E8=A3=85=E7=9B=AE=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E3=80=8C=E8=A6=86=E7=9B=96=E5=AE=89=E8=A3=85?= =?UTF-8?q?/=E5=8D=B8=E8=BD=BD=E5=8D=B3=E4=B8=A2=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:数据目录是 <安装目录>\data(config.yaml 的 dataDir 相对服务端工作目录), 而 electron-builder 的卸载器结尾执行 RMDir /r $INSTDIR,且覆盖安装会先跑旧卸载器 (installSection.nsh:52 的 uninstallOldVersion)→ 赛事库/规则/用户/审计/上传图片 连同安装目录被整个删除,再装新版。卸载一次同样如此。 - 服务端新增 -data (主命令、backup、passwd 三处),非空时覆盖 config.yaml 的 dataDir;不传时行为完全不变(兼容便携/手工运行) - Console 把数据指到当前用户目录 %LOCALAPPDATA%\ScoreboardConsole\data(卸载器不会碰 这里;没有用 app.getPath('userData'),因为 %APPDATA%\ 正是被删的那个) - 一次性迁移:目标为空且 <安装目录>\data 有数据时整体改名过去;两边都有数据时不动作 只记警告;任何失败都保留原地数据、绝不删除 - passwd 也带 -data(否则重置的是安装目录下那个空库里的账号) - deploy/backup.bat 指向同一数据目录 - 文档:部署指南新增「数据放在哪里(升级/卸载都不会删)」+ 备份/恢复/迁移路径改写, 上线检查清单与使用手册同步 自检:服务端 12 包全绿;console 语法 14 文件 + datadir 单测 11/11;backup.bat 保持纯 ASCII。 --- console/README.md | 28 +++- console/config.json | 1 + console/main/account.js | 18 +-- console/main/config.js | 4 + console/main/datadir.js | 112 +++++++++++++++ console/main/datadir.test.js | 191 ++++++++++++++++++++++++++ console/main/index.js | 42 +++++- console/main/service.js | 15 +- deploy/backup.bat | 23 ++-- docs/上线检查清单.md | 14 +- docs/使用手册.md | 2 +- docs/部署指南.md | 99 ++++++++++--- server/go.mod | 2 +- server/internal/config/config.go | 11 ++ server/internal/config/config_test.go | 34 +++++ server/main.go | 11 ++ server/main_test.go | 79 +++++++++-- server/passwd.go | 4 + 18 files changed, 626 insertions(+), 64 deletions(-) create mode 100644 console/main/datadir.js create mode 100644 console/main/datadir.test.js diff --git a/console/README.md b/console/README.md index 31666c4..6f8a491 100644 --- a/console/README.md +++ b/console/README.md @@ -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:/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 安装包在真机上的安装 / 卸载流程尚未验证。 diff --git a/console/config.json b/console/config.json index 713af91..7aa2ba0 100644 --- a/console/config.json +++ b/console/config.json @@ -2,6 +2,7 @@ "server": { "exe": "scoreboard.exe", "args": ["-config", "config.yaml"], + "dataDir": "", "host": "127.0.0.1", "port": 19540, "autoStart": true, diff --git a/console/main/account.js b/console/main/account.js index bb9aa79..2880892 100644 --- a/console/main/account.js +++ b/console/main/account.js @@ -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, diff --git a/console/main/config.js b/console/main/config.js index e4be3a6..2d37ded 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -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, diff --git a/console/main/datadir.js b/console/main/datadir.js new file mode 100644 index 0000000..18d85d3 --- /dev/null +++ b/console/main/datadir.js @@ -0,0 +1,112 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +// 数据目录必须落在安装目录之外:electron-builder 的卸载器结尾是 `RMDir /r $INSTDIR`, +// 而覆盖安装会先静默跑一遍旧版卸载器 → 装在安装目录里的 data/ 会被整体删掉。 +// 也不能放 %APPDATA%\(卸载器同样会删),所以默认落在 %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 } diff --git a/console/main/datadir.test.js b/console/main/datadir.test.js new file mode 100644 index 0000000..660014e --- /dev/null +++ b/console/main/datadir.test.js @@ -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'), /跳过旧数据迁移/) +}) diff --git a/console/main/index.js b/console/main/index.js index 4daf63e..7546a93 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -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({ diff --git a/console/main/service.js b/console/main/service.js index 0a22c52..8472ed6 100644 --- a/console/main/service.js +++ b/console/main/service.js @@ -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'], diff --git a/deploy/backup.bat b/deploy/backup.bat index a77b835..560dc7f 100644 --- a/deploy/backup.bat +++ b/deploy/backup.bat @@ -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 \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 \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\\uploads\ are NOT included; copy that +REM \event_projs\\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) ) diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 1677654..b86c9f9 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -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-备份与恢复)。 diff --git a/docs/使用手册.md b/docs/使用手册.md index c9262fd..d8a4419 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -515,7 +515,7 @@ 当前服务端一次只运行一个赛事项目(默认 `default`)。导入赛事项目包后,需要重启服务端并指定 `-project <项目名>` 才能切换,详见部署指南第 8.5 节。 **Q:导出的文件在哪里?** -结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在 `data\exports\` 下生成临时包(导出完成后自动删除)。 +结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在数据目录下的 `exports\` 里生成临时包(导出完成后自动删除)。数据目录默认是 `%LOCALAPPDATA%\ScoreboardConsole\data`,详见部署指南第 3.4 节。 **Q:登录多久会过期?** 会话有效期 12 小时。过期后自动回到只读访客视图,重新登录即可。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 6f37c53..bc77147 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -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 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台 diff --git a/server/go.mod b/server/go.mod index 0a9d06e..39cce81 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index e0ade28..072c68b 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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) diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index 743e5e9..de2fe3d 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/server/main.go b/server/main.go index d4f4225..8aa7cd8 100644 --- a/server/main.go +++ b/server/main.go @@ -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 diff --git a/server/main_test.go b/server/main_test.go index 1da2aa1..2f32b29 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -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) ===" diff --git a/server/passwd.go b/server/passwd.go index 4fccaa7..0e87325 100644 --- a/server/passwd.go +++ b/server/passwd.go @@ -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 {