Files
Scoreboard/server/internal/httpapi/server.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

443 lines
22 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.
// Package httpapi 提供 REST 接口、静态资源托管与统一响应结构。
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
"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/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。
type DisplaySender interface {
// SendDisplayCommand 下发一条大屏指令。targets 为空表示广播给所有大屏(公告靠它),
// 否则只发给这些实例 id(GET /api/display/instances 里的 id),并返回命中的实例数。
SendDisplayCommand(action string, payload any, targets []string) (matched int, err error)
}
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
// System 是系统库连接(users/sessions)。
System *sql.DB
// Event 是赛事库连接(teams/events/results/ledger/audit…)。
Event *sql.DB
// Auth 可选;为空时用 System 自动构建。
Auth *auth.Service
// DataDir 用于备份目录 data/backups。
DataDir string
// Backup 可选,手动/发布前备份共用的服务;为空时按 System/Event/DataDir 自动构建。
Backup *export.Service
// Shutdown 可选,由 main 注入:触发与 SIGINT/SIGTERM 相同的优雅关闭流程。
// 为空时 POST /api/shutdown 返回 503,避免把「没接关闭」误报成已受理。
Shutdown func()
// Display 可选,实时层接入后传入。
Display DisplaySender
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
Realtime Realtime
// WS 可选,实时层的 WebSocket 处理器,挂到 /ws。
WS http.Handler
// ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。
ProjectDir string
// Port 是服务端**实际监听**的端口,由 main 从 config 传入;GET /api/system/network
// 用它拼出「平板能直接打开的链接」。<=0(调用方没传)时按 config 的默认端口兜底,
// 不让响应里出现 :0 —— 详见 New。
Port int
// WebFS 可选,默认使用内嵌前端资源。
WebFS fs.FS
}
type server struct {
logger *slog.Logger
version string
sys *sql.DB
ev *sql.DB
auth *auth.Service
dataDir string
projectDir string
// port 是实际监听端口,只读地传给 GET /api/system/network(见 Options.Port)。
port int
backups *export.Service
shutdown func()
display DisplaySender
realtime Realtime
idem *idempotencyStore
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
svc := opts.Auth
if svc == nil {
svc = auth.New(opts.System, auth.Options{Logger: logger})
}
backups := opts.Backup
if backups == nil {
dir := ""
if strings.TrimSpace(opts.DataDir) != "" {
dir = filepath.Join(opts.DataDir, "backups")
}
backups = export.NewService(opts.System, opts.Event, dir, 0, logger)
}
// 端口取实际监听值。调用方漏传时用 config 的默认端口兜底,而不是在这里另抄一个数字:
// 响应里出现 19540 至少是个真端口,出现 :0 会让前端拼出一条根本打不开的链接。
port := opts.Port
if port <= 0 {
port = config.Default().Server.Port
}
s := &server{
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
projectDir: opts.ProjectDir,
port: port,
backups: backups,
shutdown: opts.Shutdown,
display: opts.Display,
realtime: opts.Realtime,
idem: newIdempotencyStore(10 * time.Minute),
}
mux := http.NewServeMux()
s.routes(mux)
s.exportRoutes(mux)
s.projectRoutes(mux)
s.rulesRoutes(mux)
s.systemRoutes(mux)
if opts.WS != nil {
mux.Handle("/ws", opts.WS)
}
assets := opts.WebFS
if assets == nil {
var err error
assets, err = webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
}
}
if assets != nil {
mux.Handle("/", staticHandler(assets))
}
var h http.Handler = mux
h = s.broadcastChanges(h)
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc, logger)(h)
h = middleware.CSRF(logger, h)
h = middleware.MustChangePassword(logger, h)
h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
return h
}
func middlewareUserID(r *http.Request) int64 {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return 0
}
return u.ID
}
// public 注册公开接口(guest 可读)。
func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) }
// read 注册「guest 也不可读」的接口,权限不足一律 403。
func (s *server) read(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireRole(s.logger, role)(s.handle(fn)).ServeHTTP
}
// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。
func (s *server) write(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireWrite(s.logger, role)(s.cached(s.handle(fn))).ServeHTTP
}
// writeVoid 注册无返回体的写接口。
func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return middleware.RequireWrite(s.logger, role)(s.cached(s.handleVoid(fn))).ServeHTTP
}
func (s *server) routes(mux *http.ServeMux) {
// 健康检查不走 {ok,data} 包装(05 第 7 节)。
//
// 语义分两层,写进不同字段,谁也不覆盖谁:
// ok —— HTTP 服务活着(**恒为 true,状态码也恒 200**);
// db —— 数据库能不能应答(ok / busy / error)。
// 状态码不做区分:Console 只在**进程真的退出**时才拉起(`service.js` 的 handleExit),
// 但前端 `health()` 一见到 ok:false 就直接抛错(管理端「关于」页会显示
// 「未知(/api/health 不可达)」),一次数据库抖动不该让界面宣称版本未知。
// 进程存活与依赖状态本来就是两件事,分字段表达比塞进一个布尔值诚实。
// 不返回最近备份时间:本接口会被 Console 每 5 秒轮询,不做目录扫描。
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
defer cancel()
state, err := s.probeDatabases(ctx)
resp := map[string]any{"ok": true, "version": s.version, "db": state}
if err != nil {
resp["dbError"] = err.Error()
}
writeJSON(w, http.StatusOK, resp)
})
// 认证
mux.HandleFunc("POST /api/auth/login", s.handle(s.login))
mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout))
mux.HandleFunc("GET /api/auth/me", s.handle(s.me))
mux.HandleFunc("POST /api/auth/password", s.handle(s.changePassword))
// 系统配置(guest 一律 403)
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
// 登录有效期是机器级配置(系统库 settings),与上面的赛事级 /api/settings 不共用;
// 它决定所有人的会话寿命,所以读写都限 admin。
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
// 大屏控制页要用「当前设备的局域网地址」拼出平板能直接打开的链接(可点 + 复制 + 二维码)。
// 面板自己常常是从 127.0.0.1 打开的,浏览器端点算不出本机局域网 IP,只能由服务端枚举网卡。
// 与 /api/system/about 同档:都是机器级信息,guest 一律 403。
mux.HandleFunc("GET /api/system/network", s.read(auth.RoleRecorder, s.systemNetwork))
// 基础配置
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler))
mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler))
mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler))
// 「彻底删除」是假删除(只标记不可见),这条把标记撤掉、单位回到界面。权限与删除同档。
mux.HandleFunc("POST /api/teams/{id}/restore", s.write(auth.RoleRecorder, s.restoreTeamHandler))
mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler))
mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler))
mux.HandleFunc("GET /api/sports/{id}", s.public(s.getSportHandler))
mux.HandleFunc("PUT /api/sports/{id}", s.write(auth.RoleRecorder, s.updateSportHandler))
mux.HandleFunc("DELETE /api/sports/{id}", s.writeVoid(auth.RoleRecorder, s.deleteSportHandler))
mux.HandleFunc("GET /api/events", s.public(s.listEventsHandler))
mux.HandleFunc("POST /api/events", s.write(auth.RoleRecorder, s.createEventHandler))
mux.HandleFunc("GET /api/events/{id}", s.public(s.getEventHandler))
mux.HandleFunc("PUT /api/events/{id}", s.write(auth.RoleRecorder, s.updateEventHandler))
mux.HandleFunc("DELETE /api/events/{id}", s.writeVoid(auth.RoleRecorder, s.deleteEventHandler))
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
// 单个组别的就地更新:有报名数据时改名 / 改年龄 / 改性别 / 改权重只走这条,不重建组别。
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}", s.write(auth.RoleRecorder, s.updateCategoryHandler))
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
// 团体手工名次(devdocs/17 §七):成绩页要读(guest 也能看成绩),录入员可写。
// 与成绩读取同权限:组别下没有团体手工通道时回空视图,不是 404。
mux.HandleFunc("GET /api/events/{id}/categories/{categoryId}/team-ranks", s.public(s.teamRanksHandler))
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}/team-ranks", s.write(auth.RoleRecorder, s.updateTeamRanksHandler))
mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler))
mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler))
mux.HandleFunc("GET /api/scoring-profiles/{id}", s.public(s.getProfileHandler))
mux.HandleFunc("PUT /api/scoring-profiles/{id}", s.write(auth.RoleRecorder, s.updateProfileHandler))
mux.HandleFunc("GET /api/athletes", s.public(s.listAthletesHandler))
mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler))
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler))
// 批量动作端点:一次请求完成整批停用 / 彻底删除,逐条回报成败;逐个删会逐个消耗写配额。
mux.HandleFunc("POST /api/athletes/batch", s.write(auth.RoleRecorder, s.batchAthletesHandler))
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
// 分工作表报名模板:按当前赛事的「小项 × 组别」生成,一个小项组别一张工作表(entries_sheet_template.go)。
// 是报名流程的一部分,权限与导入同档(recorder 及以上);它只读基础数据,不产生任何写入。
mux.HandleFunc("GET /api/entries/template.xlsx", s.read(auth.RoleRecorder, s.entriesTemplateXLSX))
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
// 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。
mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler))
// 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。
// 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。
mux.HandleFunc("PUT /api/entries/{id}/team-shares", s.write(auth.RoleRecorder, s.updateEntryTeamSharesHandler))
// 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。
// 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。
mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler))
// 队员(devdocs/20):整组替换一条团体报名的队员,改完当场重算并推榜——队员决定谁拿个人分、个人奖牌。
mux.HandleFunc("PUT /api/entries/{id}/athletes", s.write(auth.RoleRecorder, s.updateEntryAthletesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
// 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
// 「重新录入」把成绩退回草稿让录入员直接改(A16)。与作废同权限、同重算口径:
// 它是回滚类动作,不受 standings.live_on_publish 约束。
mux.HandleFunc("POST /api/results/{id}/reopen", s.write(auth.RoleRecorder, s.reopenResultHandler))
// 积分与排名
mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler))
mux.HandleFunc("GET /api/standings/athletes", s.public(s.athleteStandingsHandler))
mux.HandleFunc("GET /api/standings/teams/{id}/breakdown", s.public(s.breakdownHandler))
mux.HandleFunc("GET /api/adjustments", s.public(s.listAdjustmentsHandler))
mux.HandleFunc("POST /api/adjustments", s.write(auth.RoleRecorder, s.createAdjustmentHandler))
mux.HandleFunc("POST /api/adjustments/{id}/void", s.write(auth.RoleRecorder, s.voidAdjustmentHandler))
// 纪录
mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler))
// 纪录库的写入路径(C11):手工登记 / 删除历史纪录。这不是破纪录登记,
// 所以两者分开:/api/records 只碰纪录库本身,source 只收 history。
mux.HandleFunc("POST /api/records", s.write(auth.RoleRecorder, s.createRecordHandler))
mux.HandleFunc("DELETE /api/records/{id}", s.writeVoid(auth.RoleRecorder, s.deleteRecordHandler))
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
// 审核清单:guest 不可读(含未审核的登记与驳回理由),recorder 起可读。
mux.HandleFunc("GET /api/record-breaks", s.read(auth.RoleRecorder, s.listRecordBreaksHandler))
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
// 驳回 / 作废(A41):已批准的登记被驳回时要撤掉加分、回退榜单与纪录库,见 rejectRecordBreak。
mux.HandleFunc("POST /api/record-breaks/{id}/reject", s.write(auth.RoleRecorder, s.rejectRecordBreakHandler))
// 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
// 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
// 恢复是停机动作:校验 + 拍安全副本 + 换文件后优雅重启,详见 restoreBackupHandler。
mux.HandleFunc("POST /api/backup/restore", s.write(auth.RoleAdmin, s.restoreBackupHandler))
// 用户管理(系统配置,仅 admin;guest 读 403、写 401)
mux.HandleFunc("GET /api/users", s.read(auth.RoleAdmin, s.listUsersHandler))
mux.HandleFunc("POST /api/users", s.write(auth.RoleAdmin, s.createUserHandler))
mux.HandleFunc("PUT /api/users/{id}", s.write(auth.RoleAdmin, s.updateUserHandler))
mux.HandleFunc("POST /api/users/{id}/password", s.write(auth.RoleAdmin, s.resetUserPasswordHandler))
// 变更历史(recorder 及以上可读)
mux.HandleFunc("GET /api/revisions", s.read(auth.RoleRecorder, s.listRevisionsHandler))
mux.HandleFunc("GET /api/revisions/{id}", s.read(auth.RoleRecorder, s.getRevisionDetailHandler))
// 图片上传与静态访问(会标/背景/单位图标)。上传落 <ProjectDir>/uploads/,随赛事包导出;
// 静态访问要匿名放行,大屏的 <img> 才能不带会话直接取图。
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
// 图片的删除与孤儿清理(admin,与字体删除同一口径):上传是录入员的日常操作,
// 删文件是系统级资源管理。删除接口复用 uploadContentType 的名字校验与防穿越;
// 孤儿清单只列「没有任何引用点用到」的图片,批量删除只动请求里点名的文件。
mux.HandleFunc("DELETE /api/uploads/{name}", s.writeVoid(auth.RoleAdmin, s.deleteUploadHandler))
mux.HandleFunc("GET /api/uploads/orphans", s.read(auth.RoleAdmin, s.listUploadOrphansHandler))
mux.HandleFunc("POST /api/uploads/orphans/delete", s.write(auth.RoleAdmin, s.deleteUploadsHandler))
// 字体:4 款内置字体(前端 @font-face)由 GET /api/fonts 列出,上传的落
// <项目目录>/uploads/fonts/,随赛事包导出;静态访问同样是匿名放行——大屏的
// @font-face 不带会话。字体目录比图片多一层,静态路由必须单独注册。
mux.HandleFunc("GET /api/fonts", s.read(auth.RoleRecorder, s.listFontsHandler))
mux.HandleFunc("POST /api/fonts", s.write(auth.RoleAdmin, s.uploadFontHandler))
mux.HandleFunc("DELETE /api/fonts/{name}", s.writeVoid(auth.RoleAdmin, s.deleteFontHandler))
mux.HandleFunc("GET /uploads/fonts/{name}", s.serveFont)
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
s.writeAPIError(w, r, notFound("接口不存在"))
})
}
// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。
func (s *server) notWired(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.writeAPIError(w, r, notImplemented(name+"接口尚未接入"))
}
}
// healthProbeTimeout 是健康检查探库的超时。
//
// **必须明显小于 Console 的健康检查超时**(`console/main/service.js` 里 `timeout: 2500`):
// 数据库是单连接(`SetMaxOpenConns(1)`),被备份(`VACUUM INTO`)或整包重算这类长操作
// 占着的时候,探针会一直等到自己的超时——探针超时就是**整个 /api/health 请求超时**,
// Console 会把服务标成「未就绪」(状态灯变红、托盘提示异常;`console/main/index.js:671`
// 在未就绪时还会跳过重排大屏)。1 秒对一次 `SELECT 1` 绰绰有余——正常是亚毫秒,
// 走满超时只可能是「连接被占用」这一种情况,那就如实报 busy,别拖垮探活请求本身。
const healthProbeTimeout = time.Second
// probeDatabases 探活系统库与赛事库,返回状态与原因:
//
// "ok" —— 两个库都答得上话;
// "busy" —— 超时前没拿到连接(多半是备份 / 重算占着,未必是故障);
// "error" —— 真的报错了(例如库文件被删、损坏)。
//
// 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回,
// 探不到「文件还在、库已经读不动」这种情况。
func (s *server) probeDatabases(ctx context.Context) (string, error) {
for _, target := range []struct {
name string
db *sql.DB
}{{"system", s.sys}, {"event", s.ev}} {
if target.db == nil {
continue
}
var one int
if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "busy", fmt.Errorf("%s 库在 %s 内没应答(连接被备份/重算占用?): %w",
target.name, healthProbeTimeout, err)
}
return "error", fmt.Errorf("%s 库无响应: %w", target.name, err)
}
}
return "ok", nil
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}