## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
136 lines
3.7 KiB
Go
136 lines
3.7 KiB
Go
// Package config 负责加载服务端启动配置。
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
|
|
)
|
|
|
|
// Config 是 config.yaml 的映射。
|
|
type Config struct {
|
|
Server Server `yaml:"server"`
|
|
DataDir string `yaml:"dataDir"`
|
|
Backup Backup `yaml:"backup"`
|
|
Log Log `yaml:"log"`
|
|
}
|
|
|
|
// Backup 是自动备份配置。
|
|
type Backup struct {
|
|
// IntervalMinutes 是自动备份间隔(分钟);<=0 表示关闭自动备份。
|
|
IntervalMinutes int `yaml:"intervalMinutes"`
|
|
// Keep 是每个库滚动保留的份数;<=0 时用 export.DefaultBackupKeep。
|
|
Keep int `yaml:"keep"`
|
|
}
|
|
|
|
// Server 是 HTTP 监听配置。
|
|
type Server struct {
|
|
Host string `yaml:"host"`
|
|
Port int `yaml:"port"`
|
|
// AllowedOrigins 是 WebSocket 跨站 Origin 白名单;为空时只放行同源与不带 Origin 的客户端。
|
|
AllowedOrigins []string `yaml:"allowedOrigins"`
|
|
}
|
|
|
|
// Log 是日志配置。
|
|
type Log struct {
|
|
Level string `yaml:"level"`
|
|
// Dir 是日志根目录,其下分 operations/(放行类)与 interceptions/(拦截类);
|
|
// 留空时用 <dataDir>/logs。
|
|
Dir string `yaml:"dir"`
|
|
// MaxSizeMB 是单个日志文件的大小上限(MB);<=0 用 logging.DefaultMaxSizeMB。
|
|
MaxSizeMB int `yaml:"maxSizeMB"`
|
|
// KeepFiles 是每个类别保留的份数(含当前文件);<=0 用 logging.DefaultKeepFiles。
|
|
KeepFiles int `yaml:"keepFiles"`
|
|
}
|
|
|
|
// Default 返回一份可直接使用的默认配置。
|
|
func Default() *Config {
|
|
return &Config{
|
|
Server: Server{Host: "0.0.0.0", Port: 19540},
|
|
DataDir: "data",
|
|
Backup: Backup{IntervalMinutes: 5, Keep: 20},
|
|
Log: Log{
|
|
Level: "info",
|
|
MaxSizeMB: logging.DefaultMaxSizeMB,
|
|
KeepFiles: logging.DefaultKeepFiles,
|
|
},
|
|
}
|
|
}
|
|
|
|
// DirPath 返回日志根目录:配了 log.dir 就用它,否则落在数据目录下的 logs/。
|
|
// 相对路径保持相对(与 dataDir 的处理一致),现场怎么填就怎么用。
|
|
func (l Log) DirPath(dataDir string) string {
|
|
if dir := strings.TrimSpace(l.Dir); dir != "" {
|
|
return dir
|
|
}
|
|
return filepath.Join(dataDir, "logs")
|
|
}
|
|
|
|
// Load 读取配置文件;文件不存在时返回默认配置。
|
|
func Load(path string) (*Config, error) {
|
|
cfg := Default()
|
|
|
|
raw, err := os.ReadFile(path)
|
|
switch {
|
|
case err == nil:
|
|
if err := yaml.Unmarshal(raw, cfg); err != nil {
|
|
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
|
|
}
|
|
case os.IsNotExist(err):
|
|
// 没有配置文件时使用默认值
|
|
default:
|
|
return nil, fmt.Errorf("读取配置文件 %s: %w", path, err)
|
|
}
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// ApplyDataDir 用命令行显式指定的数据目录覆盖配置里的 dataDir:dir 为空或只有空白时保持原样。
|
|
// 返回是否发生了覆盖,调用方据此记录数据目录来源。
|
|
func (c *Config) ApplyDataDir(dir string) bool {
|
|
dir = strings.TrimSpace(dir)
|
|
if dir == "" {
|
|
return false
|
|
}
|
|
c.DataDir = dir
|
|
return true
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
|
return fmt.Errorf("server.port 非法: %d", c.Server.Port)
|
|
}
|
|
if strings.TrimSpace(c.DataDir) == "" {
|
|
return fmt.Errorf("dataDir 不能为空")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Addr 返回 http.Server 使用的监听地址。
|
|
func (s Server) Addr() string {
|
|
return fmt.Sprintf("%s:%d", s.Host, s.Port)
|
|
}
|
|
|
|
// SlogLevel 把配置里的日志级别映射为 slog 级别。
|
|
func (l Log) SlogLevel() slog.Level {
|
|
switch strings.ToLower(strings.TrimSpace(l.Level)) {
|
|
case "debug":
|
|
return slog.LevelDebug
|
|
case "warn", "warning":
|
|
return slog.LevelWarn
|
|
case "error":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|