根因:数据目录是 <安装目录>\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。
192 lines
7.0 KiB
JavaScript
192 lines
7.0 KiB
JavaScript
'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'), /跳过旧数据迁移/)
|
||
})
|