Files
Scoreboard/server/main.go
T
AskaEth 2c9ff65fc4 feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对)
  与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的
  重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端
- export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep),
  自动、手动、发布前三类备份共用同一把互斥锁与滚动清理
- realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行,
  跨站需命中 server.allowedOrigins)
- console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀),
  服务管理器新增「已连接的大屏实例」卡片
- deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空,
  导致最近这次构建的 exe 里根本没注入署名
- docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
2026-09-09 21:41:08 +08:00

245 lines
7.5 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)")
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)
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 {
// 初始密码只打印这一次;数据库里只有 bcrypt 哈希。
fmt.Printf("\n========================================\n"+
" 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+
"========================================\n\n", username, password)
logger.Warn("已创建默认管理员", "username", username, "password", password)
}
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)
}
}
// 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
}