Files
Scoreboard/server/internal/httpapi/server.go
T
AskaEth 830a4dbdb7 feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
2026-09-14 03:52:34 +08:00

438 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))
// 成绩
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)
}
})
}