Files
Scoreboard/server/main.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

247 lines
7.8 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)
}
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() }()
// 前缀必须与 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
}