Files
Scoreboard/server/internal/middleware/middleware.go
T
AskaEth 8dd599bd97 fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:

导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向

导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)

错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)

大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
  字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
  scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)

文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)

自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
2026-09-11 15:35:42 +08:00

344 lines
11 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 middleware
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"runtime/debug"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
const HeaderRequestID = "X-Request-Id"
// maxRequestIDLen 限制客户端 request_id 长度:它会被下游当作幂等缓存键,超长会撑内存。
const maxRequestIDLen = 128
// 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 len(id) > maxRequestIDLen {
id = ""
}
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")
}
}
// 回写请求头,让下游(幂等缓存)读到同一个受控 id。
r.Header.Set(HeaderRequestID, id)
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 放行(只读默认身份)。
// 会话查询失败必须留痕:否则系统库故障会静默把所有人降级成 guest。
func Authenticate(svc *auth.Service, 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) {
u := &auth.User{Role: auth.RoleGuest}
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
found, _, err := svc.Lookup(r.Context(), c.Value)
switch {
case err == nil:
u = found
case errors.Is(err, auth.ErrSessionNotFound):
// 会话缺失/过期是正常情况,静默按 guest 放行。
default:
logger.Error("会话查询失败",
"err", err, "path", r.URL.Path, "requestId", GetRequestID(r.Context()))
}
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
})
}
}
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
func RequireRole(logger *slog.Logger, 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) {
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权访问该资源",
map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
func RequireWrite(logger *slog.Logger, 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 {
intercept(logger, w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
return
}
if !u.Role.Allows(need) {
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权执行该操作",
map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
})
}
}
// MustChangePassword 阻止「必须修改密码」的账号执行写操作。
// 只放行改密与登出:否则用户会被永久锁在无法改密的死循环里(见 devdocs/08 第 1 节)。
func MustChangePassword(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest || !u.MustChangePassword {
next.ServeHTTP(w, r)
return
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
switch r.URL.Path {
case "/api/auth/password", "/api/auth/logout":
next.ServeHTTP(w, r)
return
}
intercept(logger, w, r, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil)
})
}
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
func CSRF(logger *slog.Logger, 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
}
// 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号,
// 因此对带 Origin/Referer 的登录请求单独做同源校验。
if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) {
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败,请刷新页面重试", nil)
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) {
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请刷新页面重试", nil)
return
}
next.ServeHTTP(w, r)
})
}
// sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer),
// 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。
func sameOrigin(r *http.Request) bool {
raw := r.Header.Get("Origin")
if raw == "" {
raw = r.Header.Get("Referer")
}
if raw == "" {
return true
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
}
// RateLimitWrite 对已登录用户的写操作限流。
// 阈值只写在 auth.DefaultWritePerMinute 一处,这里不复述具体数字——
// 写死一个副本,改常量时必然漏改(本轮就从 30 提到 120 而漏了这句注释)。
func RateLimitWrite(svc *auth.Service, logger *slog.Logger) 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) {
intercept(logger, w, r, 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,避免包循环。
// intercept 记录一次中间件层的拦截,再写出统一响应。
// 这几个中间件都在 handler 之前就返回响应,不经 httpapi 的 writeAPIError——
// 不在这里记的话,这些拦截完全没有痕迹。收成一个函数是为了「只要写了响应就一定有日志」。
// 与 httpapi 的拦截日志同一形状:WARN 给原因与拦截点,DEBUG 补上下文,两类都进 interceptions/。
func intercept(logger *slog.Logger, w http.ResponseWriter, r *http.Request, status int, code, message string, detail any) {
if logger == nil {
logger = slog.Default()
}
u := User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := GetRequestID(r.Context())
// skip=2:0 是本包 Origin 自己的帧,1 是 intercept,2 才是做校验的那个中间件函数。
origin := logging.Origin(2)
logger.Warn("请求被拦截",
"reason", message, "origin", origin, "code", code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID, "ip", ClientIP(r),
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", code, "detail", detail, "origin", origin,
"method", r.Method, "path", r.URL.Path, "ip", ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
writeError(w, status, code, message, detail)
}
// actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。
func actorLabel(u *auth.User) string {
if u == nil || u.ID == 0 {
return "guest"
}
return fmt.Sprintf("%s#%d", u.Username, u.ID)
}
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)
}