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

+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 {