## 判断失误的修正(主人现场实测出来的) 主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访), 平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址 **排除**了,于是显示「未检测到局域网地址」。 根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」 ——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。 现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**): 只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播), 链路本地与私网、全局一样进候选,只**排在最后**并带说明 「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。 新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。 顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」, 而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。 ## 三层兜底(离线现场必须拿得到地址) 1. **严格轮**:接口 up 且非回环; 2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`; 3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址 (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。 接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因, code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。 ## 手填入口(最后一道保险) 自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片, 立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、 四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。 ## 自检工具 `debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员, 打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。 (正是它把现场定性成 169.254 网段的。) ## 真浏览器验证(四场景,非源码断言) 链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/ low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。 ## 文档 使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与 「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0; devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。 **没有数据迁移**,旧赛事包照常导入。 自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。
382 lines
15 KiB
Go
382 lines
15 KiB
Go
// 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)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|