Files
Scoreboard/server/internal/middleware/middleware.go
T
AskaEth e5e0944d26 feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复
## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
  按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
  operations/     放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
  interceptions/  拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
                  DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
                  否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
  降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
  防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。

## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
  示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
  本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。

修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。

测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
2026-09-11 01:59:58 +08:00

342 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 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
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)
}