// Package log 提供前端错误上报接收端点。 // // 路由:POST /api/v1/log // 职责: // - 接收前端 useErrorReport hook 通过 sendBeacon/fetch keepalive 上报的错误 // - 使用 slog 结构化日志记录(后续可接入 OTel/Sentry) // - 返回 204 No Content(sendBeacon 不需要响应体) // // 安全: // - 已通过 api-gateway 的 JWT 鉴权中间件 // - 请求体大小限制 64KB(错误日志不需要大 body) // - 限流由全局 RateLimit 中间件保障 // // 关联:portal-shell README v2.0 §5.4 三级错误处理 package log import ( "encoding/json" "io" "log/slog" "net/http" "github.com/gin-gonic/gin" ) // maxLogBodySize 日志请求体上限:64KB const maxLogBodySize int64 = 64 * 1024 // ErrorReportPayload 前端错误上报 payload 结构 // 对齐 packages/hooks/src/use-error-report.ts 的 ErrorReportPayload type ErrorReportPayload struct { Level string `json:"level"` // "error" | "warning" Message string `json:"message"` // 错误消息 Stack string `json:"stack,omitempty"` // 调用栈 Digest string `json:"digest,omitempty"` // Next.js 错误摘要 Path string `json:"path"` // window.location.pathname UserAgent string `json:"userAgent"` // navigator.userAgent Timestamp string `json:"timestamp"` // ISO 8601 PluginID string `json:"pluginId,omitempty"` // 插件标识 UserID string `json:"userId,omitempty"` // 用户 ID Context map[string]interface{} `json:"context,omitempty"` // 额外上下文 } // HandleReport 接收前端错误上报。 // // POST /api/v1/log // Request Body: ErrorReportPayload (JSON) // Response: 204 No Content // // 容错策略: // - JSON 解析失败 → 400 + 错误消息 // - 其他错误 → 204(前端上报失败静默降级,不应阻塞) func HandleReport(c *gin.Context) { // 限制请求体大小 c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxLogBodySize) body, err := io.ReadAll(c.Request.Body) if err != nil { slog.Warn("log report: read body failed", "error", err, "request_id", c.GetString("request_id"), ) c.Status(http.StatusNoContent) return } var payload ErrorReportPayload if err := json.Unmarshal(body, &payload); err != nil { slog.Warn("log report: invalid JSON", "error", err, "request_id", c.GetString("request_id"), "body_size", len(body), ) c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid JSON payload", }) return } // 结构化日志记录(后续可替换为 OTel/Sentry exporter) slog.LogAttrs(c.Request.Context(), toSlogLevel(payload.Level), payload.Message, slog.String("logger", "frontend"), slog.String("digest", payload.Digest), slog.String("path", payload.Path), slog.String("plugin_id", payload.PluginID), slog.String("user_id", payload.UserID), slog.String("user_agent", payload.UserAgent), slog.String("timestamp", payload.Timestamp), slog.String("stack", payload.Stack), slog.Any("context", payload.Context), slog.String("request_id", c.GetString("request_id")), ) c.Status(http.StatusNoContent) } // toSlogLevel 将前端 level 映射到 slog.Level func toSlogLevel(level string) slog.Level { switch level { case "error": return slog.LevelError case "warning": return slog.LevelWarn case "info": return slog.LevelInfo default: return slog.LevelError } }