## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
135 lines
3.8 KiB
Go
135 lines
3.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
|
)
|
|
|
|
// AuditEntry 是一条待落库的审计记录;Before/After 为任意可 JSON 序列化的快照。
|
|
type AuditEntry struct {
|
|
// Action 用点号分层,关键动作单独成段,如 result.publish / result.void / user.create。
|
|
Action string
|
|
EntityType string
|
|
EntityID int64
|
|
// ActorID 非 0 时优先作为审计主体,用于登录等「身份刚建立、请求 ctx 里仍是 guest」的动作。
|
|
ActorID int64
|
|
Before any
|
|
After any
|
|
}
|
|
|
|
type auditCollector struct {
|
|
entries []AuditEntry
|
|
}
|
|
|
|
// Record 由 handler 调用,登记一条审计;仅在请求成功(2xx)时落库。
|
|
func Record(ctx context.Context, e AuditEntry) {
|
|
if c, ok := ctx.Value(ctxKeyAudit).(*auditCollector); ok {
|
|
c.entries = append(c.entries, e)
|
|
}
|
|
}
|
|
|
|
// Auditor 把成功写操作的审计记录写入赛事库 audit_logs(只追加、不修改)。
|
|
type Auditor struct {
|
|
db *sql.DB
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewAuditor 创建审计中间件;db 为赛事库(event.db)。
|
|
func NewAuditor(db *sql.DB, logger *slog.Logger) *Auditor {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Auditor{db: db, log: logger}
|
|
}
|
|
|
|
// Middleware 收集 handler 登记的审计并在成功后落库。
|
|
func (a *Auditor) Middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
c := &auditCollector{}
|
|
ctx := context.WithValue(r.Context(), ctxKeyAudit, c)
|
|
next.ServeHTTP(sw, r.WithContext(ctx))
|
|
|
|
if sw.status >= http.StatusBadRequest || len(c.entries) == 0 {
|
|
return
|
|
}
|
|
a.flush(ctx, r, c.entries)
|
|
})
|
|
}
|
|
|
|
func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEntry) {
|
|
if a.db == nil {
|
|
return
|
|
}
|
|
u := User(ctx)
|
|
requestID := GetRequestID(ctx)
|
|
ip := ClientIP(r)
|
|
|
|
for _, e := range entries {
|
|
var entityID any
|
|
if e.EntityID != 0 {
|
|
entityID = e.EntityID
|
|
}
|
|
// 登录等动作在请求 ctx 里还没有登录身份,改由条目自带的 ActorID 补上。
|
|
actor := nullableID(u)
|
|
if e.ActorID != 0 {
|
|
actor = e.ActorID
|
|
}
|
|
_, err := a.db.ExecContext(ctx,
|
|
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
actor, e.Action, e.EntityType, entityID,
|
|
marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID)
|
|
if err != nil {
|
|
// 审计失败不能吞掉业务结果,但必须留下痕迹。
|
|
a.log.Error("写入审计失败", "err", err, "action", e.Action, "requestId", requestID)
|
|
continue
|
|
}
|
|
// 落库成功即「这次操作被放行」:默认 info 级别下这是唯一的放行痕迹,
|
|
// 与拦截的 WARN 成对,现场才能对上「谁在什么时候做了什么」。
|
|
a.log.Info("操作已放行",
|
|
"action", e.Action,
|
|
"entity", e.EntityType,
|
|
"entityId", e.EntityID,
|
|
"actor", auditActorLabel(u, e.ActorID),
|
|
"requestId", requestID)
|
|
}
|
|
}
|
|
|
|
// auditActorLabel 是放行日志里的操作人:登录请求的身份来自条目自带的 ActorID,
|
|
// 其余来自会话;两边都拿不到就是 guest。记「用户名#id」比裸 id 好认。
|
|
func auditActorLabel(u *auth.User, actorID int64) string {
|
|
switch {
|
|
case actorID != 0:
|
|
return fmt.Sprintf("user#%d", actorID)
|
|
case u != nil && u.ID != 0:
|
|
return fmt.Sprintf("%s#%d", u.Username, u.ID)
|
|
default:
|
|
return "guest"
|
|
}
|
|
}
|
|
|
|
func nullableID(u *auth.User) any {
|
|
if u == nil || u.Role == auth.RoleGuest || u.ID == 0 {
|
|
return nil
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
func marshalOrNil(v any) any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return string(raw)
|
|
}
|