Files
Scoreboard/server/internal/logging/logging.go
T
AskaEth 8dd599bd97 fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 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。
2026-09-11 15:35:42 +08:00

164 lines
5.1 KiB
Go
Raw 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
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
}