Files
Scoreboard/server/main.go
T
AskaEth 005b51b2ec 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。
2026-09-10 09:17:56 +08:00

263 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}