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

+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) ==="