Files
Scoreboard/server/main.go
T
AskaEth 9ef68c2589 feat(console,admin): kiosk 不再点击穿透 + 压住任务栏;大屏控制页新增「局域网大屏」(链接/复制/二维码)dev_0.4.0
## ① 现场问题:kiosk 窗口点击穿透(原设计方向反了)
`displays.js` 原来对多屏 kiosk 调 `setIgnoreMouseEvents(true)`,注释写的是
「防止现场误触」——但该 API 的语义是「本窗口忽略鼠标」,点击会**落到它后面的
窗口或桌面**,现场想防的误触正好变成误点别的东西;被忽略鼠标的窗口也点不活。
现在窗口**默认吞掉鼠标、不穿透**。

配置项**改名**为 `display.allowClickThrough`(缺省 false;显式 true 才穿透)。
**旧键 `display.ignoreMouse` 完全不读**——老机器的配置文件里还写着旧版模板留下的
`ignoreMouse: true`,而配置策略是「已存在不覆盖」,只改默认值等于老机器升级后
照样穿透;改名才能让默认值真正生效。启动时若发现旧键会记一条中文提示(只记一次),
并在合并后的配置里把它剥掉,防止将来有人再读到那个陈年 true。
(不是自动改写用户配置文件:无法区分「操作员刻意写的 true」与「旧模板留下的 true」。)

## ② 现场问题:任务栏盖住 kiosk 窗口、被盖住后抢不回前台
原顺序是 `setKiosk(true)` 之后又 `setBounds(...)`,在 Windows 上会把窗口从真正的
全屏态拽回「恰好铺满该屏的无边框窗口」→ 不是 topmost,任务栏与拖过去的窗口都盖得住。
现在固定为 **先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true,
'screen-saver')`**(默认层级 `floating` 在 Windows 上仍画在任务栏下面),并加了
「已达标就不重复进出 kiosk」的守卫避免重排白闪;非 kiosk 分支会摘掉置顶。
Win 真机行为需现场复核,代码/桩级已验(含顺序与层级的断言)。

## ③ 新功能:大屏控制页「局域网大屏」卡片
- 服务端新增 `GET /api/system/network`(recorder 及以上):回本机可用局域网 IPv4
  (排除回环/link-local/IPv6,去重,按 192.168/10/172.16-31 优先排序)+ 真实监听端口
  + 路径;无地址回 `[]` 不报错。
- 卡片:每个地址一条可点击链接 + 复制按钮,第一个地址配二维码(148px)。
- **二维码是离线自实现的纯 TS 编码器**(版本 1–40 自动、ECC L/M/Q/H、8 种掩码择优),
  正确性用 **Python OpenCV 真解码**验收:29/29 条解出原文(含中文与边界),
  SVG 渲染后再解码 4/4,并把验证过的矩阵冻结成仓库参照用例。
- 复制按**非安全上下文**处理:面板常从 `http://192.168.x.x` 打开,那里
  `navigator.clipboard` 是 undefined,自动回退 `execCommand('copy')`。

## ④ 真机联调抓出并修掉一个时序缺陷(真浏览器 + 真服务端)
`DisplayPage` 的 `refreshLan()` 在挂载时跑,而 `auth` 是异步加载的——那一刻
`auth.readOnly` 必然为 true,卡片把「当前为只读访客」写进一个**普通 ref** 就返回了,
身份落定后没有任何东西再触发它:**管理员登录后卡片永远停在访客态,
`/api/system/network` 一次都没被请求**。
改为「gate 由 computed 直接渲染 + watch gate 变 null 时重跑加载」,并补回归钉子。
(同时修正了一条把该错误结构当成正确行为的源码断言用例。)
端到端证据:真服务 + 局域网 IP(真非安全上下文)→ 卡片列出两个地址、点击复制变
「已复制」(走 execCommand 回退)→ 浏览器渲染的二维码截图经 cv2 解出
`http://10.50.101.85:19861/display`。

## 文档
部署指南配置表与多屏行为、使用手册「大屏控制」与局域网设备段、devdocs 05/07/10/13、
升级说明新增 dev_0.4.0(含旧键废弃与启动提示)。**无数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 645 例 ×2 + build;Console 20 文件 + 68 例。
2026-09-12 16:25:04 +08:00

378 lines
15 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"
"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)
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)
}
}
// 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
}