## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
97 lines
3.5 KiB
Go
97 lines
3.5 KiB
Go
// Package logging 负责服务端日志的落盘:按「放行 / 拦截」两类分流到不同目录,
|
||
// 各自按天 + 按大小轮转并保留固定份数,写盘失败时降级为只写控制台。
|
||
//
|
||
// 单独成包是为了让 httpapi 与 middleware 共用同一份「拦截点」捕获与类别标记,
|
||
// 又不会互相依赖(两边都只依赖本包)。
|
||
package logging
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
)
|
||
|
||
// 类别标记:日志按「是不是我们主动拦截的」分流,而不是只看级别——
|
||
// DEBUG 里既有常规访问日志,也有拦截上下文,只按级别分不开。
|
||
const (
|
||
// ChannelKey 是类别标记的属性名;拦截类日志都带上它。
|
||
ChannelKey = "log.channel"
|
||
// ChannelInterception 是拦截类的取值。
|
||
ChannelInterception = "interception"
|
||
)
|
||
|
||
// sourceRoot 是本包所在模块的根目录(…/server);用它把编译期路径裁成
|
||
// 「internal/httpapi/xxx.go」这样的仓库内相对路径,日志里不出现开发机的目录结构。
|
||
// 用 -trimpath 构建时 file 本来就是相对路径,裁剪会自然退化成原样。
|
||
var sourceRoot = func() string {
|
||
_, file, _, ok := runtime.Caller(0)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
// 本文件位于 <root>/internal/logging/origin.go,向上三级即 <root>。
|
||
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
|
||
}()
|
||
|
||
// relativeSourcePath 把编译期文件路径裁成仓库内相对路径;裁不动时只留文件名。
|
||
func relativeSourcePath(file string) string {
|
||
if sourceRoot != "" {
|
||
if rel := strings.TrimPrefix(file, sourceRoot+string(filepath.Separator)); rel != file {
|
||
return rel
|
||
}
|
||
}
|
||
return filepath.Base(file)
|
||
}
|
||
|
||
// shortFuncName 尽量给出「(*server).createEvent」「RequireWrite」这样的短名。
|
||
// 编译器会把闭包按内联链写成 (*server).write.(*server).write.RequireWrite.func1.func4,
|
||
// 直接取最后一段会得到一串噪音,这里只保留「有意义的那个函数名 + 第一个闭包序号」。
|
||
func shortFuncName(full string) string {
|
||
if i := strings.LastIndex(full, "/"); i >= 0 {
|
||
full = full[i+1:]
|
||
}
|
||
rest := full
|
||
if i := strings.Index(rest, "."); i >= 0 {
|
||
rest = rest[i+1:]
|
||
}
|
||
parts := strings.Split(rest, ".")
|
||
if len(parts) == 1 {
|
||
return rest
|
||
}
|
||
|
||
// 跳过尾部的闭包序号,落回真正定义它的那个函数。
|
||
owner := len(parts) - 1
|
||
for owner > 0 && strings.HasPrefix(parts[owner], "func") {
|
||
owner--
|
||
}
|
||
name := parts[owner]
|
||
// 闭包序号只留第一个:够区分同一个函数里的多个闭包。
|
||
if owner+1 < len(parts) && strings.HasPrefix(parts[owner+1], "func") {
|
||
name += "." + parts[owner+1]
|
||
}
|
||
// 方法带上接收者,「(*server).createEvent」比「createEvent」好找。
|
||
if owner > 0 && strings.HasPrefix(parts[owner-1], "(*") {
|
||
name = parts[owner-1] + "." + name
|
||
}
|
||
return name
|
||
}
|
||
|
||
// FormatOrigin 拼出「internal/middleware/middleware.go:123 RequireRole」。
|
||
func FormatOrigin(file string, line int, fn string) string {
|
||
return fmt.Sprintf("%s:%d %s", relativeSourcePath(file), line, shortFuncName(fn))
|
||
}
|
||
|
||
// Origin 捕获调用点(skip 从本函数自己那层算起),产出「文件:行 函数名」。
|
||
// 日志里必须能指出是哪段代码拦下的,否则现场只有一句中文提示,定位不到校验代码。
|
||
func Origin(skip int) string {
|
||
pc, file, line, ok := runtime.Caller(skip)
|
||
if !ok {
|
||
return "unknown"
|
||
}
|
||
fn := "unknown"
|
||
if f := runtime.FuncForPC(pc); f != nil {
|
||
fn = f.Name()
|
||
}
|
||
return FormatOrigin(file, line, fn)
|
||
}
|