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 { // 5xx 的原始错误串(SQLite / 引擎)在这里留痕,现场看日志能定位; // 响应体里也有一份(见 response.go 的 internalMsg),前端不需要翻服务端日志。 // role / ip 与下面 4xx 的 WARN 同口径:按日志查「谁干的」时,5xx 不能缺一半线索。 logger.Error("请求失败", "code", api.code, "err", api.message, "detail", api.logDetail, "origin", api.origin, "method", r.Method, "path", r.URL.Path, "actor", actor, "role", role, "ip", middleware.ClientIP(r), "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) }