根因:数据目录是 <安装目录>\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。
263 lines
8.7 KiB
Go
263 lines
8.7 KiB
Go
// Command scoreboard 是赛事积分榜系统的服务端。
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
_ "modernc.org/sqlite"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||
)
|
||
|
||
// version 由构建时注入:-ldflags "-X main.version=..."
|
||
var version = "dev_0.1.0"
|
||
|
||
func main() {
|
||
if len(os.Args) > 1 && os.Args[1] == "backup" {
|
||
if err := runBackup(os.Args[2:]); err != nil {
|
||
fmt.Fprintf(os.Stderr, "备份失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
return
|
||
}
|
||
if len(os.Args) > 1 && os.Args[1] == "passwd" {
|
||
if err := runPasswd(os.Args[2:]); err != nil {
|
||
fmt.Fprintf(os.Stderr, "重置密码失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
return
|
||
}
|
||
|
||
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)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
|
||
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 {
|
||
logger.Error("打开系统库失败", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
defer func() { _ = st.Close() }()
|
||
|
||
if err := st.Migrate(); err != nil {
|
||
logger.Error("系统库迁移失败", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
eventDB, err := openEventDB(cfg.DataDir, *project)
|
||
if err != nil {
|
||
logger.Error("打开赛事库失败", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
defer func() { _ = eventDB.Close() }()
|
||
if err := store.MigrateEvent(eventDB); err != nil {
|
||
logger.Error("赛事库迁移失败", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", *project)
|
||
|
||
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
|
||
hub := realtime.NewHub(eventDB, logger)
|
||
hub.SetAllowedOrigins(cfg.Server.AllowedOrigins)
|
||
defer func() { _ = hub.Close() }()
|
||
|
||
// 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。
|
||
// 反向代理部署时用 SCOREBOARD_TRUSTED_PROXIES 指定代理 IP/CIDR,逗号分隔。
|
||
if raw := strings.TrimSpace(os.Getenv("SCOREBOARD_TRUSTED_PROXIES")); raw != "" {
|
||
if err := middleware.SetTrustedProxies(strings.Split(raw, ",")); err != nil {
|
||
logger.Error("可信代理配置非法", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
if username, password, created, err := authSvc.EnsureDefaultAdmin(ctx); err != nil {
|
||
logger.Error("初始化默认管理员失败", "err", err)
|
||
os.Exit(1)
|
||
} else if created {
|
||
fmt.Print(initialAdminBanner(username, password))
|
||
logger.Warn("已创建默认管理员,初始密码仅在上方一次性输出,不写入日志", "username", username)
|
||
}
|
||
|
||
backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger)
|
||
|
||
srv := &http.Server{
|
||
Addr: cfg.Server.Addr(),
|
||
Handler: httpapi.New(httpapi.Options{
|
||
Logger: logger,
|
||
Version: version,
|
||
System: st.DB(),
|
||
Event: eventDB,
|
||
Auth: authSvc,
|
||
DataDir: cfg.DataDir,
|
||
Backup: backupSvc,
|
||
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
|
||
Shutdown: stop,
|
||
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
|
||
Display: displaySender{hub: hub},
|
||
Realtime: hub,
|
||
WS: hub.Handler(),
|
||
}),
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
}
|
||
|
||
// 定时备份跟随服务端生命周期:收到退出信号(ctx 取消)即停止。
|
||
backupSvc.StartAuto(ctx, time.Duration(cfg.Backup.IntervalMinutes)*time.Minute)
|
||
|
||
go func() {
|
||
logger.Info("服务启动", "addr", srv.Addr, "version", version)
|
||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
logger.Error("服务异常退出", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
}()
|
||
|
||
<-ctx.Done()
|
||
|
||
logger.Info("正在关闭…")
|
||
// Console 的关闭握手要求 3 秒内完成,超时后仍会关库并退出(defer 顺序保证)。
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
logger.Error("关闭超时", "err", err)
|
||
}
|
||
}
|
||
|
||
// initialAdminBanner 组装初始管理员提示块:初始密码只在这里一次性输出,不写入结构化日志。
|
||
// 末尾的纯 ASCII 锚供现场在 logs\console.log 里检索——中文 findstr 在 chcp 936/65001 下不可靠。
|
||
func initialAdminBanner(username, password string) string {
|
||
return fmt.Sprintf("\n========================================\n"+
|
||
" 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+
|
||
"=== INITIAL ADMIN CREATED (password above) ===\n"+
|
||
"========================================\n\n", username, password)
|
||
}
|
||
|
||
// displaySender 把 httpapi 的大屏命令接口适配到实时层。
|
||
type displaySender struct{ hub *realtime.Hub }
|
||
|
||
func (d displaySender) SendDisplayCommand(action string, payload any) error {
|
||
cmd, err := realtime.NewDisplayCommand(action, payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
d.hub.SendCommand(cmd)
|
||
return nil
|
||
}
|
||
|
||
// runBackup 实现 `scoreboard backup` 子命令:内置 VACUUM INTO,不依赖外部 sqlite3.exe。
|
||
func runBackup(args []string) error {
|
||
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||
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
|
||
}
|
||
|
||
cfg, err := config.Load(*configPath)
|
||
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
|
||
}
|
||
|
||
sysDB, err := openSQLite(filepath.Join(cfg.DataDir, "system.db"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = sysDB.Close() }()
|
||
if _, err := export.Backup(sysDB, export.BackupOptions{Dir: dir, Prefix: "system", Reason: *reason}); err != nil {
|
||
return err
|
||
}
|
||
|
||
evPath := filepath.Join(cfg.DataDir, "event_projs", *project, "event.db")
|
||
if _, err := os.Stat(evPath); err == nil {
|
||
evDB, err := openSQLite(evPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = evDB.Close() }()
|
||
// 前缀必须与 Service 一致:PruneBackups 只清理 <prefix>-*,用项目名会让 CLI 备份只增不减。
|
||
if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: "event", Reason: *reason}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
items, err := export.ListBackups(dir, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("备份完成:%s(现有 %d 份)\n", dir, len(items))
|
||
return nil
|
||
}
|
||
|
||
// openEventDB 打开(必要时创建)赛事库;store 包不持有赛事库连接,
|
||
// 由 main 负责生命周期,再交给 httpapi 与实时层使用。
|
||
func openEventDB(dataDir, project string) (*sql.DB, error) {
|
||
dir := filepath.Join(dataDir, "event_projs", project)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("创建赛事目录 %s: %w", dir, err)
|
||
}
|
||
return openSQLite(filepath.Join(dir, "event.db"))
|
||
}
|
||
|
||
func openSQLite(path string) (*sql.DB, error) {
|
||
dsn := "file:" + path +
|
||
"?_pragma=journal_mode(WAL)" +
|
||
"&_pragma=foreign_keys(1)" +
|
||
"&_pragma=busy_timeout(5000)"
|
||
|
||
db, err := sql.Open("sqlite", dsn)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开数据库 %s: %w", path, err)
|
||
}
|
||
db.SetMaxOpenConns(1)
|
||
if err := db.Ping(); err != nil {
|
||
_ = db.Close()
|
||
return nil, fmt.Errorf("连接数据库 %s: %w", path, err)
|
||
}
|
||
return db, nil
|
||
}
|