feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package middleware 提供请求日志、错误恢复、认证/授权与审计等 HTTP 中间件。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxKeyUser ctxKey = iota
|
||||
ctxKeyRequestID
|
||||
ctxKeyAudit
|
||||
)
|
||||
|
||||
// User 返回当前请求的身份;未经 Authenticate 中间件时是 guest。
|
||||
func User(ctx context.Context) *auth.User {
|
||||
if u, ok := ctx.Value(ctxKeyUser).(*auth.User); ok {
|
||||
return u
|
||||
}
|
||||
return &auth.User{Role: auth.RoleGuest}
|
||||
}
|
||||
|
||||
// WithUser 把身份写入 context,供测试或非标准链路使用。
|
||||
func WithUser(ctx context.Context, u *auth.User) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyUser, u)
|
||||
}
|
||||
|
||||
// GetRequestID 返回本次请求的 request_id。
|
||||
func GetRequestID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyRequestID).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ClientIP 从代理头或连接地址解析客户端 IP。
|
||||
func ClientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
|
||||
return first
|
||||
}
|
||||
}
|
||||
if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" {
|
||||
return rip
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
)
|
||||
|
||||
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
|
||||
const HeaderRequestID = "X-Request-Id"
|
||||
|
||||
// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get(HeaderRequestID)
|
||||
if id == "" {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err == nil {
|
||||
id = hex.EncodeToString(buf)
|
||||
} else {
|
||||
id = time.Now().UTC().Format("20060102150405.000000")
|
||||
}
|
||||
}
|
||||
w.Header().Set(HeaderRequestID, id)
|
||||
next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
func contextWithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyRequestID, id)
|
||||
}
|
||||
|
||||
// Recover 捕获 handler panic,返回统一 500,避免进程被单个请求带崩。
|
||||
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
logger.Error("请求处理 panic",
|
||||
"err", rec,
|
||||
"path", r.URL.Path,
|
||||
"requestId", GetRequestID(r.Context()),
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL", "服务端异常", nil)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Log 记录访问日志(方法、路径、状态、耗时、request_id)。
|
||||
func Log(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
logger.Debug("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
"dur", time.Since(start),
|
||||
"requestId", GetRequestID(r.Context()),
|
||||
"ip", ClientIP(r),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。
|
||||
func Authenticate(svc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := &auth.User{Role: auth.RoleGuest}
|
||||
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
|
||||
if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil {
|
||||
u = found
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
|
||||
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
|
||||
func RequireRole(need auth.Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !User(r.Context()).Role.Allows(need) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
|
||||
func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := User(r.Context())
|
||||
if u.Role == auth.RoleGuest {
|
||||
writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
|
||||
return
|
||||
}
|
||||
if !u.Role.Allows(need) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
|
||||
func CSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if User(r.Context()).Role == auth.RoleGuest {
|
||||
// 未登录请求由 RequireWrite 处理,无需 CSRF。
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
var cookie string
|
||||
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
|
||||
cookie = c.Value
|
||||
}
|
||||
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
|
||||
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
u := User(r.Context())
|
||||
if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) {
|
||||
writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// statusWriter 记录响应状态码,供日志与审计判断是否成功。
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.written {
|
||||
w.status = code
|
||||
w.written = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if !w.written {
|
||||
w.written = true
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Hijack 透传底层连接:WebSocket 升级依赖 http.Hijacker,
|
||||
// 包装 ResponseWriter 时必须显式转发,否则握手会退化成 501。
|
||||
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hj, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
// Flush 透传,保证流式响应不被缓冲。
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。
|
||||
func writeError(w http.ResponseWriter, status int, code, message string, detail any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
body := map[string]any{
|
||||
"ok": false,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if detail != nil {
|
||||
body["error"].(map[string]any)["detail"] = detail
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
Reference in new issue
Block a user