## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
106 lines
3.3 KiB
Go
106 lines
3.3 KiB
Go
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 客户端超长 request_id 会被下游当作幂等键缓存,必须作废并换成服务端生成的 id(P2-6)。
|
||
func TestRequestIDClampsOversizedHeader(t *testing.T) {
|
||
var (
|
||
ctxID string
|
||
handlerID string
|
||
)
|
||
h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ctxID = GetRequestID(r.Context())
|
||
handlerID = r.Header.Get(HeaderRequestID)
|
||
}))
|
||
req := httptest.NewRequest(http.MethodPost, "/api/teams", nil)
|
||
req.Header.Set(HeaderRequestID, strings.Repeat("x", maxRequestIDLen+1))
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
|
||
if ctxID == "" || len(ctxID) > maxRequestIDLen {
|
||
t.Fatalf("超长 id 应换成服务端生成的短 id,得到 %q", ctxID)
|
||
}
|
||
if handlerID != ctxID {
|
||
t.Fatalf("下游读到的 request_id 应与 ctx 一致,ctx=%q header=%q", ctxID, handlerID)
|
||
}
|
||
if got := rec.Header().Get(HeaderRequestID); got != ctxID {
|
||
t.Fatalf("响应头 request_id 应为 %q,得到 %q", ctxID, got)
|
||
}
|
||
}
|
||
|
||
// 合理长度的客户端 id 仍应原样复用,保证幂等回放。
|
||
func TestRequestIDKeepsNormalHeader(t *testing.T) {
|
||
var ctxID string
|
||
h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ctxID = GetRequestID(r.Context())
|
||
}))
|
||
req := httptest.NewRequest(http.MethodPost, "/api/teams", nil)
|
||
req.Header.Set(HeaderRequestID, "abc-123")
|
||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||
|
||
if ctxID != "abc-123" {
|
||
t.Fatalf("正常 id 应原样复用,得到 %q", ctxID)
|
||
}
|
||
}
|
||
|
||
// 登录是 guest 请求,CSRF Token 校验被跳过,靠同源校验挡住 login CSRF(P2-6)。
|
||
func TestCSRFBlocksCrossOriginLogin(t *testing.T) {
|
||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||
h := CSRF(nil, next)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||
req.Host = "scoreboard.local"
|
||
req.Header.Set("Origin", "http://evil.example")
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("跨站登录应 403,得到 %d", rec.Code)
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||
req.Host = "scoreboard.local"
|
||
req.Header.Set("Origin", "http://scoreboard.local")
|
||
rec = httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("同源登录应放行,得到 %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// 同源判断:两个头都没有时放行脚本调用,Origin: null 与解析失败一律拒绝。
|
||
func TestSameOrigin(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
origin string
|
||
referer string
|
||
want bool
|
||
}{
|
||
{"无来源头", "", "", true},
|
||
{"同源 Origin", "http://scoreboard.local", "", true},
|
||
{"跨站 Origin", "http://evil.example", "", false},
|
||
{"Origin 为 null", "null", "", false},
|
||
{"同源 Referer", "", "http://scoreboard.local/admin", true},
|
||
{"跨站 Referer", "", "http://evil.example/x", false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||
req.Host = "scoreboard.local"
|
||
if tc.origin != "" {
|
||
req.Header.Set("Origin", tc.origin)
|
||
}
|
||
if tc.referer != "" {
|
||
req.Header.Set("Referer", tc.referer)
|
||
}
|
||
if got := sameOrigin(req); got != tc.want {
|
||
t.Fatalf("sameOrigin = %v,期望 %v", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|