## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
92 lines
3.7 KiB
Go
92 lines
3.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
|
)
|
|
|
|
// 本文件把「拦截点」(哪一行、哪个函数拦下的)记进拦截日志。
|
|
// 主动拦截的报错必须自带拦截点:默认级别是 info,现场只看得见 WARN 那一行,
|
|
// 只给一句中文提示的话,排查时仍然不知道是哪段校验拦的。
|
|
// 捕获实现放在 internal/logging,与 middleware 共用一份,避免两边各写一套。
|
|
|
|
// callerOrigin 捕获调用点;skip 从 callerOrigin 自己算起(见 newAPIError 的说明),
|
|
// 这里 +1 是把自己这一层算进 logging.Origin 的帧数。
|
|
func callerOrigin(skip int) string {
|
|
return logging.Origin(skip + 1)
|
|
}
|
|
|
|
// originFromStack 兜底:错误不是经构造函数造的(例如把底层错误原样返回出来)时,
|
|
// 沿栈找到第一个不在 response.go / 本文件里的帧,就是返回这个错误的那段业务代码。
|
|
func originFromStack(skip int) string {
|
|
pcs := make([]uintptr, 16)
|
|
n := runtime.Callers(skip, pcs)
|
|
frames := runtime.CallersFrames(pcs[:n])
|
|
for {
|
|
frame, more := frames.Next()
|
|
if frame.File != "" && !strings.HasSuffix(frame.File, "response.go") &&
|
|
!strings.HasSuffix(frame.File, "origin.go") && !strings.Contains(frame.Function, "/internal/logging.") {
|
|
return logging.FormatOrigin(frame.File, frame.Line, frame.Function)
|
|
}
|
|
if !more {
|
|
return "unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
// interceptRow 生成给前端的行级错误文案,并顺手留一条 DEBUG 拦截日志(拦进 interceptions/)。
|
|
// 导入的行级错误原本只进响应体,服务端不留痕的话,现场「我明明填对了它却说格式不对」没法复盘。
|
|
func (s *server) interceptRow(row int, format string, args ...any) string {
|
|
reason := fmt.Sprintf(format, args...)
|
|
s.logger.Debug("导入行被拦截",
|
|
"row", row,
|
|
"reason", reason,
|
|
"origin", callerOrigin(2),
|
|
logging.ChannelKey, logging.ChannelInterception,
|
|
)
|
|
return fmt.Sprintf("第 %d 行:%s", row, reason)
|
|
}
|
|
|
|
// logIntercepted 记录一次被拦截的请求。
|
|
// WARN 是默认级别(info)下现场唯一看得见的一行,所以「为什么被拦」与「拦截点」都放在它身上;
|
|
// DEBUG 只补上下文(code/detail/ip/角色),不能只靠它。带类别标记的行会进 interceptions/。
|
|
// 这里绝不整段记请求体:里面有证件号这类个人信息,`detail` 与响应同源,属既有泄露面。
|
|
func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
|
|
u := middleware.User(r.Context())
|
|
actor := actorLabel(u)
|
|
role := u.Role.String()
|
|
reqID := middleware.GetRequestID(r.Context())
|
|
|
|
if api.status >= http.StatusInternalServerError {
|
|
logger.Error("请求失败",
|
|
"code", api.code, "err", api.message, "origin", api.origin,
|
|
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
|
|
return
|
|
}
|
|
logger.Warn("请求被拦截",
|
|
"reason", api.message, "origin", api.origin, "code", api.code,
|
|
"method", r.Method, "path", r.URL.Path,
|
|
"actor", actor, "role", role, "requestId", reqID,
|
|
logging.ChannelKey, logging.ChannelInterception)
|
|
logger.Debug("拦截上下文",
|
|
"code", api.code, "detail", api.detail, "origin", api.origin,
|
|
"method", r.Method, "path", r.URL.Path, "ip", middleware.ClientIP(r),
|
|
"actor", actor, "role", role, "requestId", reqID,
|
|
logging.ChannelKey, logging.ChannelInterception)
|
|
}
|
|
|
|
// 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)
|
|
}
|