// Package httpapi 提供 REST 接口、静态资源托管与统一响应结构。 package httpapi import ( "database/sql" "io/fs" "log/slog" "net/http" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/webui" ) // DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。 type DisplaySender interface { SendDisplayCommand(action string, payload any) 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 // Display 可选,实时层接入后传入。 Display DisplaySender // WS 可选,实时层的 WebSocket 处理器,挂到 /ws。 WS http.Handler // WebFS 可选,默认使用内嵌前端资源。 WebFS fs.FS } type server struct { logger *slog.Logger version string sys *sql.DB ev *sql.DB auth *auth.Service dataDir string display DisplaySender 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}) } s := &server{ logger: logger, version: opts.Version, sys: opts.System, ev: opts.Event, auth: svc, dataDir: opts.DataDir, display: opts.Display, idem: newIdempotencyStore(10 * time.Minute), } mux := http.NewServeMux() s.routes(mux) s.exportRoutes(mux) s.rulesRoutes(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 = middleware.NewAuditor(opts.Event, logger).Middleware(h) h = middleware.RateLimitWrite(svc)(h) h = middleware.CSRF(h) h = middleware.Authenticate(svc)(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(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(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(role)(s.cached(s.handleVoid(fn))).ServeHTTP } func (s *server) routes(mux *http.ServeMux) { // 健康检查不走 {ok,data} 包装(05 第 7 节)。 mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version}) }) // 认证 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)) // 系统配置(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.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler)) mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler)) // 成绩 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/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler)) mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler)) // 由其他模块负责的接口:先占位,避免落到静态资源处理器。 mux.HandleFunc("/api/export/", s.notWired("导出")) mux.HandleFunc("/api/rules/", s.notWired("规则导入导出")) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeAPIError(w, s.logger, notFound("接口不存在")) }) } // notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。 func (s *server) notWired(name string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { writeAPIError(w, s.logger, notImplemented(name+"接口尚未接入")) } } // 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) } }) }