// Command scoreboard 是赛事积分榜系统的服务端。 package main import ( "context" "database/sql" "errors" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "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/realtime" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // version 由构建时注入:-ldflags "-X main.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", "配置文件路径") project := flag.String("project", "default", "赛事项目名(data/event_projs/<项目名>/event.db)") flag.Parse() cfg, err := config.Load(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) os.Exit(1) } logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.SlogLevel()})) slog.SetDefault(logger) 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) defer func() { _ = hub.Close() }() 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 { // 初始密码只打印这一次;数据库里只有 bcrypt 哈希。 fmt.Printf("\n========================================\n"+ " 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+ "========================================\n\n", username, password) logger.Warn("已创建默认管理员", "username", username, "password", password) } 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, Display: displaySender{hub: hub}, WS: hub.Handler(), }), ReadHeaderTimeout: 10 * time.Second, } 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("正在关闭…") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("关闭超时", "err", err) } } // 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") if err := fs.Parse(args); err != nil { return err } cfg, err := config.Load(*configPath) 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", *project, "event.db") if _, err := os.Stat(evPath); err == nil { evDB, err := openSQLite(evPath) if err != nil { return err } defer func() { _ = evDB.Close() }() if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: *project, 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 }