根因:数据目录是 <安装目录>\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。
119 lines
4.1 KiB
Go
119 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// CLI 备份必须用 event 前缀(与备份服务一致),否则 PruneBackups 按 <prefix>-* 清理不到,
|
|
// 备份目录会只增不减。
|
|
func TestRunBackupUsesEventPrefix(t *testing.T) {
|
|
root := t.TempDir()
|
|
dataDir := filepath.Join(root, "data")
|
|
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)
|
|
}
|
|
|
|
entries, err := os.ReadDir(filepath.Join(dataDir, "backups"))
|
|
if err != nil {
|
|
t.Fatalf("读取备份目录: %v", err)
|
|
}
|
|
eventBackup := false
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "default-") {
|
|
t.Errorf("CLI 备份不应使用项目名前缀: %s", e.Name())
|
|
}
|
|
if strings.HasPrefix(e.Name(), "event-manual-") {
|
|
eventBackup = true
|
|
}
|
|
}
|
|
if !eventBackup {
|
|
t.Errorf("未生成 event- 前缀的赛事库备份,实际文件: %v", entries)
|
|
}
|
|
}
|
|
|
|
// 数据目录搬到安装目录之外后,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) ==="
|
|
banner := initialAdminBanner("admin", "s3cret-pass")
|
|
if !strings.Contains(banner, anchor) {
|
|
t.Fatalf("banner 缺少可搜索的 ASCII 锚 %q:\n%s", anchor, banner)
|
|
}
|
|
if !strings.Contains(banner, "s3cret-pass") {
|
|
t.Fatalf("banner 应包含初始密码:\n%s", banner)
|
|
}
|
|
// 锚在密码之后,文案 password above 才成立。
|
|
if strings.Index(banner, "s3cret-pass") > strings.Index(banner, anchor) {
|
|
t.Fatalf("ASCII 锚应在密码行之后:\n%s", banner)
|
|
}
|
|
}
|