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:
1 parent
6483acf9ad
commit
005b51b2ec
18 files changed
+626
-64
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) ==="
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user