- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
105 lines
2.6 KiB
Go
105 lines
2.6 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"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
|
|
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
|
|
}
|
|
_, err := a.db.ExecContext(ctx,
|
|
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
nullableID(u), 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|