feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
This commit is contained in:
110
services/api-gateway/internal/log/handler.go
Normal file
110
services/api-gateway/internal/log/handler.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user