Files
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

97 lines
3.5 KiB
Go
Raw Permalink 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 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)
}