先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:
导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向
导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)
错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)
大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)
文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)
自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
164 lines
5.1 KiB
Go
164 lines
5.1 KiB
Go
package logging
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
)
|
||
|
||
// 默认值:现场不配 config.yaml 的 log 段也能开箱即用,且不会把盘写满。
|
||
const (
|
||
// DirOperations 与 DirInterceptions 是两类日志的子目录名。
|
||
DirOperations = "operations"
|
||
DirInterceptions = "interceptions"
|
||
DefaultMaxSizeMB = 20
|
||
DefaultKeepFiles = 10
|
||
operationsPrefix = "operations"
|
||
interceptionPrefix = "interceptions"
|
||
)
|
||
|
||
// Options 是日志装配参数。
|
||
type Options struct {
|
||
// Dir 是日志根目录,其下分 operations/ 与 interceptions/ 两个子目录。
|
||
Dir string
|
||
// Level 是放行类(与控制台)的采集级别,来自 config.yaml 的 log.level。
|
||
Level slog.Level
|
||
// MaxSizeMB 是单个文件的大小上限(MB),<=0 用默认值。
|
||
MaxSizeMB int
|
||
// KeepFiles 是每个类别保留的份数(含当前文件),<=0 用默认值。
|
||
KeepFiles int
|
||
// Console 是控制台输出目标,留空用 os.Stdout(保持原有控制台行为)。
|
||
Console io.Writer
|
||
}
|
||
|
||
// New 组装日志处理器:控制台保持现状(全量、按 Level 过滤),
|
||
// 文件按「放行 / 拦截」两类分流——拦截类固定按 DEBUG 采集,不看 log.level,
|
||
// 因为默认级别是 info,跟着过滤就看不到现场要的那份详细拦截日志了。
|
||
// 返回的 io.Closer 负责关文件,进程退出前调用。
|
||
func New(opts Options) (slog.Handler, io.Closer) {
|
||
console := opts.Console
|
||
if console == nil {
|
||
console = os.Stdout
|
||
}
|
||
maxBytes := int64(opts.MaxSizeMB) * 1024 * 1024
|
||
if maxBytes <= 0 {
|
||
maxBytes = int64(DefaultMaxSizeMB) * 1024 * 1024
|
||
}
|
||
keep := opts.KeepFiles
|
||
if keep <= 0 {
|
||
keep = DefaultKeepFiles
|
||
}
|
||
dir := opts.Dir
|
||
|
||
// 降级告警只发一次:控制台那条链路是现成的,且不会绕回文件处理器造成递归。
|
||
consoleLogger := slog.New(slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}))
|
||
var warnOnce sync.Once
|
||
warn := func(err error) {
|
||
warnOnce.Do(func() {
|
||
consoleLogger.Warn("日志文件不可写,已降级为只写控制台", "dir", dir, "err", err)
|
||
})
|
||
}
|
||
|
||
// 两个目录在启动时就建好。拦截类目录原先只在「第一次拦截发生」时才被创建,
|
||
// 现场验收时会以为「目录不存在 = 日志坏了」——排查工具本身不能让人起疑。
|
||
// 建不出来不影响启动,交给 warn 走降级告警。
|
||
if dir != "" {
|
||
for _, sub := range []string{DirOperations, DirInterceptions} {
|
||
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
|
||
warn(fmt.Errorf("创建日志目录 %s: %w", sub, err))
|
||
}
|
||
}
|
||
}
|
||
|
||
ops := newRotator(filepath.Join(dir, DirOperations), operationsPrefix, maxBytes, keep, warn)
|
||
inter := newRotator(filepath.Join(dir, DirInterceptions), interceptionPrefix, maxBytes, keep, warn)
|
||
|
||
// 放行类跟着 log.level 过滤;拦截类固定 Debug 采集,不跟着过滤(现场要的就是那份详细日志)。
|
||
return &handler{
|
||
console: slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}),
|
||
ops: slog.NewJSONHandler(ops, &slog.HandlerOptions{Level: opts.Level}),
|
||
inter: slog.NewJSONHandler(inter, &slog.HandlerOptions{Level: slog.LevelDebug}),
|
||
}, closerFunc(func() error { return closerAll(ops, inter) })
|
||
}
|
||
|
||
type handler struct {
|
||
console slog.Handler
|
||
ops slog.Handler
|
||
inter slog.Handler
|
||
}
|
||
|
||
func (h *handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||
return h.console.Enabled(ctx, level) || h.ops.Enabled(ctx, level) || h.inter.Enabled(ctx, level)
|
||
}
|
||
|
||
func (h *handler) Handle(ctx context.Context, rec slog.Record) error {
|
||
// 控制台照旧:所有类别都打,按配置级别过滤。
|
||
if h.console.Enabled(ctx, rec.Level) {
|
||
if err := h.console.Handle(ctx, rec); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// 文件按类别分流:拦截类固定 Debug 采集,放行类按配置级别。
|
||
if isInterception(rec) {
|
||
if h.inter.Enabled(ctx, rec.Level) {
|
||
return h.inter.Handle(ctx, rec)
|
||
}
|
||
return nil
|
||
}
|
||
if h.ops.Enabled(ctx, rec.Level) {
|
||
return h.ops.Handle(ctx, rec)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||
return &handler{
|
||
console: h.console.WithAttrs(attrs),
|
||
ops: h.ops.WithAttrs(attrs),
|
||
inter: h.inter.WithAttrs(attrs),
|
||
}
|
||
}
|
||
|
||
func (h *handler) WithGroup(name string) slog.Handler {
|
||
return &handler{
|
||
console: h.console.WithGroup(name),
|
||
ops: h.ops.WithGroup(name),
|
||
inter: h.inter.WithGroup(name),
|
||
}
|
||
}
|
||
|
||
// isInterception 判断一条日志是不是「我们主动拦截 / 服务端异常」:
|
||
// 拦截类日志带类别标记;ERROR 级别的异常即使没标记也归拦截类,它们是排查用的。
|
||
func isInterception(rec slog.Record) bool {
|
||
if rec.Level >= slog.LevelError {
|
||
return true
|
||
}
|
||
marked := false
|
||
rec.Attrs(func(a slog.Attr) bool {
|
||
if a.Key == ChannelKey && a.Value.String() == ChannelInterception {
|
||
marked = true
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
return marked
|
||
}
|
||
|
||
type closerFunc func() error
|
||
|
||
func (f closerFunc) Close() error { return f() }
|
||
|
||
func closerAll(cs ...io.Closer) error {
|
||
var first error
|
||
for _, c := range cs {
|
||
if err := c.Close(); err != nil && first == nil {
|
||
first = err
|
||
}
|
||
}
|
||
return first
|
||
}
|