Files
Scoreboard/server/internal/httpapi/server.go
T
AskaEth b4bdaef9ba fix: 健康检查探库超时 5s → 1s(Console 探活超时只有 2500ms)
上一版把探库超时写成 5s,比 Console 的探活超时(console/main/service.js:389
的 timeout: 2500)还长。数据库是单连接,备份 VACUUM INTO 或整包重算占住连接时,
探针会等满 5s,于是整个 /api/health 请求超时,Console 把服务标成「未就绪」——
状态灯变红、托盘提示异常,console/main/index.js:671 在未就绪时还会跳过大屏重排。
为了「避免误判健康」加的探库,反而制造了另一种误判。

- healthProbeTimeout 5s → 1s:正常 SELECT 1 是亚毫秒,走满超时只可能是连接被占。
- probeDatabases 返回三态 ok / busy / error:busy 表示超时前没拿到连接(未必是故障,
  多半是备份或重算占着),error 才是真报错,两者分的错因不同,别混成一句话。
- 新增 TestHealthProbeStaysBoundedWhenDatabaseBusy:用事务占住赛事库唯一连接,
  断言 /api/health 仍回 200、db:"busy"、耗时 < 2000ms(这个数就是挡住超时被调回 2.5s 以上的护栏)。
- 更正一处写错的理由:状态码保持 200 与「避免重启循环」无关——setHealthy(false)
  只改状态与 UI,重启只发生在进程真的退出时(service.js 的 handleExit)。真正理由是
  前端 health() 见到 ok:false 会抛错,「关于」页会显示「未知(/api/health 不可达)」,
  一次数据库抖动不该让界面宣称版本未知。

版本号 dev_0.1.2 → dev_0.1.3。
2026-09-11 14:42:57 +08:00

372 lines
16 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/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
// 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
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)
}
s := &server{
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
projectDir: opts.ProjectDir,
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))
// 基础配置
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("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("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
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/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
// 成绩
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))
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
// 积分与排名
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))
mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler))
mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler))
// 大屏控制与运维
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))
// 用户管理(系统配置,仅 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)
// 字体: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)
}
})
}