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 节「服务端日志与拦截复盘」)。
This commit is contained in:
AskaEth committed 2026-09-11 01:59:58 +08:00
1 parent 8e707e1e9b
commit e5e0944d26
32 files changed
+2017 -138

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
// 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)
}