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

96 lines
3.1 KiB
Go

package config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestDefaultAllowedOrigins(t *testing.T) {
if got := Default().Server.AllowedOrigins; len(got) != 0 {
t.Fatalf("默认白名单应为空: %v", got)
}
}
func TestLoadAllowedOrigins(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
raw := "server:\n allowedOrigins:\n - http://a.example\n - https://b.example\n"
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
want := []string{"http://a.example", "https://b.example"}
if !reflect.DeepEqual(cfg.Server.AllowedOrigins, want) {
t.Fatalf("白名单解析错误: got %v want %v", cfg.Server.AllowedOrigins, want)
}
}
// -data 只在非空时覆盖配置:不传与传空串都必须保持配置文件里的值,
// 否则 Console 之外的便携/手工运行会被改写数据目录。
func TestApplyDataDir(t *testing.T) {
cases := []struct {
name string
dir string
want string
applied bool
}{
{"非空覆盖配置值", `/var/lib/scoreboard`, `/var/lib/scoreboard`, true},
{"两侧空白会被去掉", " /var/lib/scoreboard ", `/var/lib/scoreboard`, true},
{"空串保持配置值", "", "cfg-data", false},
{"纯空白等于不传", " ", "cfg-data", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("dataDir: cfg-data\nserver:\n port: 19540\n"), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
if got := cfg.ApplyDataDir(tc.dir); got != tc.applied {
t.Fatalf("ApplyDataDir(%q) 覆盖标记: got %v want %v", tc.dir, got, tc.applied)
}
if cfg.DataDir != tc.want {
t.Fatalf("ApplyDataDir(%q) 后 DataDir: got %q want %q", tc.dir, cfg.DataDir, tc.want)
}
})
}
}
// 日志配置:默认值开箱即用,配了 log.dir 就按它走,不配就落在 <dataDir>/logs。
func TestLogConfigDefaultsAndDirPath(t *testing.T) {
def := Default().Log
if def.Level != "info" {
t.Fatalf("默认级别应为 info,得到 %q", def.Level)
}
if def.MaxSizeMB <= 0 || def.KeepFiles <= 0 {
t.Fatalf("大小上限与保留份数都要有默认值,得到 %+v", def)
}
if got := def.DirPath("/data/sb"); got != filepath.Join("/data/sb", "logs") {
t.Fatalf("未配 dir 时应落在数据目录下的 logs,得到 %q", got)
}
path := filepath.Join(t.TempDir(), "config.yaml")
raw := "log:\n level: warn\n dir: /var/log/scoreboard\n maxSizeMB: 5\n keepFiles: 3\n"
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
if cfg.Log.Level != "warn" || cfg.Log.MaxSizeMB != 5 || cfg.Log.KeepFiles != 3 {
t.Fatalf("log 段解析错误: %+v", cfg.Log)
}
if got := cfg.Log.DirPath("/data/sb"); got != "/var/log/scoreboard" {
t.Fatalf("配了 dir 就该用它,得到 %q", got)
}
}