Files
Scoreboard/server/main.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

215 lines
6.1 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"
"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
}
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
}