## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
207 lines
4.7 KiB
Go
207 lines
4.7 KiB
Go
package logging
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// rotator 是「按天 + 按大小」轮转的文件写入器,并按份数清理旧文件。
|
|
//
|
|
// 现场要跑一整天,日志既不能无限涨到把盘写满,也不能因为写不进去就影响业务:
|
|
// 一旦写盘失败就永久降级(只通知一次),后续日志由控制台那条链路兜住。
|
|
type rotator struct {
|
|
dir string
|
|
prefix string
|
|
maxBytes int64
|
|
keep int
|
|
// onError 只在第一次写盘失败时回调一次:每条日志都刷告警会把控制台淹掉。
|
|
onError func(error)
|
|
|
|
mu sync.Mutex
|
|
file *os.File
|
|
size int64
|
|
curName string
|
|
curDay string
|
|
curIdx int
|
|
broken bool
|
|
}
|
|
|
|
func newRotator(dir, prefix string, maxBytes int64, keep int, onError func(error)) *rotator {
|
|
return &rotator{dir: dir, prefix: prefix, maxBytes: maxBytes, keep: keep, onError: onError}
|
|
}
|
|
|
|
// Write 永不返回错误:日志写不进去是运维问题,不能变成业务请求失败。
|
|
func (w *rotator) Write(p []byte) (int, error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.broken {
|
|
return len(p), nil
|
|
}
|
|
if err := w.prepare(len(p), time.Now()); err != nil {
|
|
w.degrade(err)
|
|
return len(p), nil
|
|
}
|
|
n, err := w.file.Write(p)
|
|
w.size += int64(n)
|
|
if err != nil {
|
|
w.degrade(err)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// Close 关掉当前文件;进程退出前调用,保证缓冲里的内容不会丢。
|
|
func (w *rotator) Close() error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.file == nil {
|
|
return nil
|
|
}
|
|
err := w.file.Close()
|
|
w.file = nil
|
|
return err
|
|
}
|
|
|
|
func (w *rotator) degrade(err error) {
|
|
w.broken = true
|
|
if w.file != nil {
|
|
_ = w.file.Close()
|
|
w.file = nil
|
|
}
|
|
if w.onError != nil {
|
|
w.onError(err)
|
|
}
|
|
}
|
|
|
|
// prepare 保证当前打开的是「今天该写、且还装得下」的那个文件。
|
|
func (w *rotator) prepare(need int, now time.Time) error {
|
|
day := now.Format("20060102")
|
|
if w.file == nil || day != w.curDay {
|
|
if err := w.closeLocked(); err != nil {
|
|
return err
|
|
}
|
|
w.curDay = day
|
|
w.curIdx = 0
|
|
return w.openLocked()
|
|
}
|
|
// 当前文件已经装了东西且放不下这一条时才轮转:空文件永远装得下,
|
|
// 否则超长单条会把每个新文件立刻撑爆、无限轮转。
|
|
if w.size > 0 && w.size+int64(need) > w.maxBytes {
|
|
if err := w.closeLocked(); err != nil {
|
|
return err
|
|
}
|
|
w.curIdx++
|
|
return w.openLocked()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *rotator) closeLocked() error {
|
|
if w.file == nil {
|
|
return nil
|
|
}
|
|
err := w.file.Close()
|
|
w.file = nil
|
|
return err
|
|
}
|
|
|
|
func (w *rotator) openLocked() error {
|
|
if err := os.MkdirAll(w.dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
name := w.fileName(w.curDay, w.curIdx)
|
|
f, err := os.OpenFile(filepath.Join(w.dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
w.file = f
|
|
w.size = info.Size()
|
|
w.curName = name
|
|
w.prune()
|
|
return nil
|
|
}
|
|
|
|
// fileName 是「<类别>-<YYYYMMDD>.log」,同一天的第 N 个续写文件加序号:
|
|
// operations-20260911.log / operations-20260911.1.log / .2.log…
|
|
func (w *rotator) fileName(day string, idx int) string {
|
|
if idx <= 0 {
|
|
return fmt.Sprintf("%s-%s.log", w.prefix, day)
|
|
}
|
|
return fmt.Sprintf("%s-%s.%d.log", w.prefix, day, idx)
|
|
}
|
|
|
|
// prune 清掉超出保留份数的最旧文件;调用方必须持有锁。
|
|
// 轮转与开新文件时都会走一遍,保留策略才有执行点(只在启动时清会一直涨)。
|
|
func (w *rotator) prune() {
|
|
if w.keep <= 0 {
|
|
return
|
|
}
|
|
files := w.list()
|
|
if len(files) <= w.keep {
|
|
return
|
|
}
|
|
for _, f := range files[:len(files)-w.keep] {
|
|
if f.name == w.curName {
|
|
continue
|
|
}
|
|
_ = os.Remove(filepath.Join(w.dir, f.name))
|
|
}
|
|
}
|
|
|
|
type logFile struct {
|
|
name string
|
|
day string
|
|
index int
|
|
}
|
|
|
|
// list 列出本类别的日志文件,按「日期 + 序号」从旧到新排序。
|
|
// 只认自己写出来的命名,别人的文件一律不碰。
|
|
func (w *rotator) list() []logFile {
|
|
entries, err := os.ReadDir(w.dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
prefix := w.prefix + "-"
|
|
out := make([]logFile, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log") {
|
|
continue
|
|
}
|
|
rest := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".log")
|
|
day, idxText, _ := strings.Cut(rest, ".")
|
|
if len(day) != 8 {
|
|
continue
|
|
}
|
|
idx := 0
|
|
if idxText != "" {
|
|
n, err := strconv.Atoi(idxText)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
idx = n
|
|
}
|
|
out = append(out, logFile{name: name, day: day, index: idx})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].day != out[j].day {
|
|
return out[i].day < out[j].day
|
|
}
|
|
return out[i].index < out[j].index
|
|
})
|
|
return out
|
|
}
|