Files
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

411 lines
16 KiB
Go
Raw Permalink 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"
"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 只清理 <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
}