// Command scoreboard 是赛事积分榜系统的服务端。 package main import ( "context" "database/sql" "encoding/json" "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/logging" "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 是服务端版本号,供 /api/health 与管理端「关于」页使用。 // 真实版本来自仓库根的 VERSION 文件,由 deploy/build.bat 在构建时用 // -ldflags "-X main.version=..." 注入;下面这行只是没走构建脚本(例如 go run)时的兜底。 // 兜底值故意不写具体版本号:写死一个数字迟早会和 VERSION 漂移,报出一个"看着像真版本"的假值; // 这样直接跑源码时一眼就能看出「这不是构建产物」。 var version = "dev" 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", "配置文件路径") // 留空表示「按记忆文件里记住的上一次赛事」:Console 拉起服务端时不传 -project, // 管理端切换赛事后靠这里读回新项目,否则导入的赛事包永远不会被打开。 project := flag.String("project", "", "赛事项目名(data/event_projs/<项目名>/event.db);留空则用记住的上一次赛事,再没有就用 default") 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) // 控制台输出保持原样;文件按「放行 / 拦截」两类分流落盘(见 internal/logging)。 // 日志写不进去也不能影响服务:装配阶段不返回错误,运行期由 logging 降级为只写控制台。 logHandler, closeLogs := logging.New(logging.Options{ Dir: cfg.Log.DirPath(cfg.DataDir), Level: cfg.Log.SlogLevel(), MaxSizeMB: cfg.Log.MaxSizeMB, KeepFiles: cfg.Log.KeepFiles, }) defer func() { _ = closeLogs.Close() }() logger := slog.New(logHandler) slog.SetDefault(logger) if dataDirFromFlag { logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir) } // 上一次恢复没走完就退出时留下的标记:必须在这里读一次,否则现场最需要线索的那次事故 // 只能靠人去数据目录里翻一个没人提过的文件。 warnPendingRestore(logger, cfg.DataDir) // 启动时把本机局域网地址记一条:大屏控制页在别的设备上打开,卡片显示空态时现场未必 // 拿得到服务端窗口,这行日志(有地址是 INFO、没有是带原因的 WARN)是排障的第一站。 httpapi.LogLanAddresses(logger) projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project) if err != nil { logger.Error("解析赛事项目失败", "err", err) os.Exit(1) } // 显式指定过就写回记忆文件:下次不带 -project 启动(Console 就是这样拉起的)仍打开同一个赛事。 if projectSource == projectSourceFlag { if err := httpapi.WriteActiveProject(cfg.DataDir, projectName); err != nil { logger.Warn("写回赛事项目记忆失败,本次仍按 -project 打开", "err", err) } } logger.Info("当前赛事项目", "project", projectName, "source", projectSource) 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, projectName) 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", projectName) authSvc := auth.New(st.DB(), auth.Options{Logger: logger}) hub := realtime.NewHub(eventDB, logger) hub.SetAllowedOrigins(cfg.Server.AllowedOrigins) // 会话复查:/ws 的身份在握手时固化,会话被撤销后必须断开对应的长连接。 hub.SetSessionChecker(authSvc) 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) apiOpts := httpapi.Options{ Logger: logger, Version: version, System: st.DB(), Event: eventDB, Auth: authSvc, DataDir: cfg.DataDir, Backup: backupSvc, // 实际监听端口:GET /api/system/network 用它拼大屏控制页上的局域网链接, // 必须与下面 srv.Addr 用的是同一个值。 Port: cfg.Server.Port, // POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。 Shutdown: stop, ProjectDir: filepath.Join(cfg.DataDir, "event_projs", projectName), Display: displaySender{hub: hub}, Realtime: hub, WS: hub.Handler(), } // 老库升级自愈(迁移 0020 只建 channel_ranks、不回填历史名次)。 // 必须在这台大屏开始拉快照之前同步跑完,否则奖牌榜会先亮出一帧全 0。 // 失败不拦启动:现场正开着赛事,重算留到下一次发布 / 规则导入重试,服务本身要能起来。 if _, err := httpapi.HealChannelRanksOnce(ctx, apiOpts); err != nil { logger.Error("通道名次自愈失败,奖牌榜可能仍为空;可重新发布成绩或导入规则包触发重算", "err", err) } srv := &http.Server{ Addr: cfg.Server.Addr(), Handler: httpapi.New(apiOpts), 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) } // 自动备份协程必须在这里 join,否则进程可能在 VACUUM INTO 中途退出。 // 它不等锁吗?会等:源库被别的连接锁着时,VACUUM INTO 会带着一个 0 字节的目标文件 // 在那里等锁(直到 busy_timeout)——进程此时退出,那个半截文件就留在备份目录里。 // 上限 10 秒是给「卡在锁上」留的(SQLite busy_timeout 5 秒);大库的 VACUUM 也可能更久, // 但 Console 的关闭握手等不起,超时就照常退出:残余的只会是一个中间文件 // <备份名>.tmp-<随机>,它不在备份列表里,也没人会拿它去恢复。 if !waitBackupStop(backupSvc, 10*time.Second) { logger.Warn("自动备份未在限期内收尾,先退出;残余的中间文件不会出现在备份列表里") } } // waitBackupStop 在退出前 join 自动备份协程,最多等 timeout;返回 false 表示等超时了。 // // 没起过自动备份(间隔 <=0、没配数据目录)时立即返回 true:正常关停不会被它拖住。 func waitBackupStop(svc *export.Service, timeout time.Duration) bool { done := make(chan struct{}) go func() { svc.Wait() close(done) }() timer := time.NewTimer(timeout) defer timer.Stop() select { case <-done: return true case <-timer.C: return false } } // 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) } // warnPendingRestore 启动时读一次「恢复进行中」标记(数据目录根部的 pending_restore.json)。 // // 这个标记由「一键恢复」在替换库文件之前写下、正常收尾(无论成功还是失败)时立刻删掉, // 所以它还留在数据目录里只有一种解释:上一次恢复没走到收尾(被强杀 / 掉电 / 蓝屏)。 // 在那之前全仓没有任何一行代码读它——恢复出事后现场最需要的线索就静静躺在数据目录根部, // 它既不在数据目录清单里,日志里也没有半点影子。这里把它变成启动时的一条 WARN。 // // 只读、不删:它是那一次未收尾恢复的唯一记录,删不删该由操作员决定(下一次成功的恢复会自己删掉它)。 // 内容解析不了同样不拦启动:它只是线索,不是数据;库能不能打开由后面各步各自把关。 func warnPendingRestore(logger *slog.Logger, dataDir string) { if strings.TrimSpace(dataDir) == "" { return } path := filepath.Join(dataDir, export.PendingRestoreFile) raw, err := os.ReadFile(path) if os.IsNotExist(err) { return } if err != nil { logger.Warn("读取上次恢复的标记失败", "path", path, "err", err) return } var pending export.PendingRestore if err := json.Unmarshal(raw, &pending); err != nil { logger.Warn("上次恢复的标记内容无法解析,请手工检查这个文件", "path", path, "err", err) return } logger.Warn("检测到上一次「一键恢复」未收尾的标记:恢复中途进程退出了", "path", path, "kind", string(pending.Kind), "backupName", pending.BackupName, "target", pending.Target, "safetyCopy", pending.SafetyCopy, "operator", pending.Operator, "requestedAt", pending.RequestedAt.Format(time.RFC3339), "hint", "当前库可能停在替换后的状态;要重做就用备份再恢复一次,"+ "要退回替换前的现场就用上面那份安全副本恢复") } // displaySender 把 httpapi 的大屏命令接口适配到实时层。 type displaySender struct{ hub *realtime.Hub } func (d displaySender) SendDisplayCommand(action string, payload any, targets []string) (int, error) { cmd, err := realtime.NewDisplayCommand(action, payload) if err != nil { return 0, err } return d.hub.SendCommandToInstances(targets, cmd) } // 赛事项目名的来源,只用于启动日志:现场据此确认这次打开的是哪一个赛事。 const ( defaultProjectName = "default" projectSourceFlag = "命令行 -project" projectSourceFile = "记忆文件 " + httpapi.ActiveProjectFile projectSourceDefault = "默认" ) // resolveProjectName 按「显式 -project → 数据目录里的记忆文件 → default」确定当前赛事项目名。 // 只解析与校验、不落盘:写回记忆文件是服务端启动路径自己的事,backup / passwd 不该顺手改掉它。 func resolveProjectName(dataDir, flagName string) (name, source string, err error) { if v := strings.TrimSpace(flagName); v != "" { safe, err := export.SafeProjectName(v) if err != nil { return "", "", fmt.Errorf("赛事项目名 %q 非法: %w", v, err) } return safe, projectSourceFlag, nil } if remembered := httpapi.ReadActiveProject(dataDir); remembered != "" { return remembered, projectSourceFile, nil } return defaultProjectName, projectSourceDefault, 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) // 项目名也按同一顺序解析:备份的必须是服务端当前打开的那个赛事库。 projectName, _, err := resolveProjectName(cfg.DataDir, *project) if err != nil { return err } 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", projectName, "event.db") if _, err := os.Stat(evPath); err == nil { evDB, err := openSQLite(evPath) if err != nil { return err } defer func() { _ = evDB.Close() }() // 前缀必须与 Service 一致:PruneBackups 只清理 -*,用项目名会让 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 }