feat: M3-M7 全模块落地 + 集成接线

- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
package middleware
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net"
"net/http"
"runtime/debug"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
const HeaderRequestID = "X-Request-Id"
// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get(HeaderRequestID)
if id == "" {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err == nil {
id = hex.EncodeToString(buf)
} else {
id = time.Now().UTC().Format("20060102150405.000000")
}
}
w.Header().Set(HeaderRequestID, id)
next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id)))
})
}
func contextWithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKeyRequestID, id)
}
// Recover 捕获 handler panic,返回统一 500,避免进程被单个请求带崩。
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("请求处理 panic",
"err", rec,
"path", r.URL.Path,
"requestId", GetRequestID(r.Context()),
"stack", string(debug.Stack()),
)
writeError(w, http.StatusInternalServerError, "INTERNAL", "服务端异常", nil)
}
}()
next.ServeHTTP(w, r)
})
}
}
// Log 记录访问日志(方法、路径、状态、耗时、request_id)。
func Log(logger *slog.Logger) func(http.Handler) http.Handler {
if logger == nil {
logger = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
logger.Debug("http",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"dur", time.Since(start),
"requestId", GetRequestID(r.Context()),
"ip", ClientIP(r),
)
})
}
}
// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。
func Authenticate(svc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := &auth.User{Role: auth.RoleGuest}
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil {
u = found
}
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
})
}
}
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
func RequireRole(need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !User(r.Context()).Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest {
writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
return
}
if !u.Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
})
}
}
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
func CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if User(r.Context()).Role == auth.RoleGuest {
// 未登录请求由 RequireWrite 处理,无需 CSRF。
next.ServeHTTP(w, r)
return
}
var cookie string
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
cookie = c.Value
}
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil)
return
}
next.ServeHTTP(w, r)
})
}
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
u := User(r.Context())
if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) {
writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// statusWriter 记录响应状态码,供日志与审计判断是否成功。
type statusWriter struct {
http.ResponseWriter
status int
written bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.written {
w.status = code
w.written = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if !w.written {
w.written = true
}
return w.ResponseWriter.Write(b)
}
// Hijack 透传底层连接:WebSocket 升级依赖 http.Hijacker,
// 包装 ResponseWriter 时必须显式转发,否则握手会退化成 501。
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return hj.Hijack()
}
// Flush 透传,保证流式响应不被缓冲。
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。
func writeError(w http.ResponseWriter, status int, code, message string, detail any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
body := map[string]any{
"ok": false,
"error": map[string]any{
"code": code,
"message": message,
},
}
if detail != nil {
body["error"].(map[string]any)["detail"] = detail
}
_ = json.NewEncoder(w).Encode(body)
}