From f129a9cfca29905c7559598f7c59dc948917fbaa Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 16 Jun 2026 10:24:14 +0800 Subject: [PATCH] feat(api): implement global error handler middleware and trace integration - Introduce APIError type and AbortWithError helper in response package - Implement errorHandlerMiddleware to record Go errors to Otel Spans and format JSON response - Register errorHandlerMiddleware globally in router - Refactor logs analytics handler to use the new unified error pattern --- AGENTS.md | 2 +- internal/apps/admin/logs/routers.go | 18 +++++++++-------- internal/common/response/response.go | 23 ++++++++++++++++++++++ internal/router/middlewares.go | 29 ++++++++++++++++++++++++++++ internal/router/router.go | 2 +- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec0a7432..6d520326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ Handler 规范: - Handler 命名为 动词 + 名词,例如 `ListUsers`。 - 使用 `ShouldBindQuery` 或 `ShouldBindJSON` 进行绑定。 - 成功时统一通过导入 `"github.com/Rain-kl/Wavelet/internal/common/response"` 使用 `response.OK(data)` 或 `response.OKNil()` 返回。 -- 失败时统一通过 `response.Err(msg)` 返回。 +- 失败时统一通过 `response.AbortWithError(c, code, msg)` 返回(这会自动将业务错误和状态码挂载到 Context 并中断请求,全局中间件捕获后会将其完整记录到 OpenTelemetry Trace/Jaeger,并格式化输出 JSON)。 - API 响应的外层结构必须为 `{ "error_msg": "", "data": ... }`。 - 分页响应在 `data` 下使用 `{ "total": 0, "results": [] }`。 - 每个 HTTP API 都需要有完整的 Swagger 注释;在 API 变更后运行 `make swagger`。 diff --git a/internal/apps/admin/logs/routers.go b/internal/apps/admin/logs/routers.go index 5d6d2ca3..78504343 100644 --- a/internal/apps/admin/logs/routers.go +++ b/internal/apps/admin/logs/routers.go @@ -5,7 +5,8 @@ // Package logs 提供日志查询与分析功能 package logs -import ("context" +import ( + "context" "encoding/json" "fmt" "net/http" @@ -21,7 +22,8 @@ import ("context" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/common/response") + "github.com/Rain-kl/Wavelet/internal/common/response" +) const ( defaultLimit = 200 @@ -57,7 +59,7 @@ func GetLogs(c *gin.Context) { var cursor, limit int if _, err := parsePositiveInt(cursorStr, &cursor); err != nil { - c.JSON(http.StatusBadRequest, response.Err(admin.InvalidCursorParam)) + response.AbortWithError(c, http.StatusBadRequest, admin.InvalidCursorParam) return } if _, err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 { @@ -288,7 +290,7 @@ func fetchAccessLogDetails(ctx context.Context, whereClause string, args []inter func GetAccessLogs(c *gin.Context) { // 1. 检查 ClickHouse 是否启用 if !config.Config.ClickHouse.Enabled || db.ChConn == nil { - c.JSON(http.StatusBadRequest, response.Err("ClickHouse 存储服务未启用,无法检索访问日志")) + response.AbortWithError(c, http.StatusBadRequest, "ClickHouse 存储服务未启用,无法检索访问日志") return } @@ -309,7 +311,7 @@ func GetAccessLogs(c *gin.Context) { // 3. 构建过滤条件 conditions, args, userIDs, err := buildAccessLogFilters(c.Request.Context(), c) if err != nil { - c.JSON(http.StatusInternalServerError, response.Err(err.Error())) + response.AbortWithError(c, http.StatusInternalServerError, err.Error()) return } if userIDs != nil && len(userIDs) == 0 { @@ -326,7 +328,7 @@ func GetAccessLogs(c *gin.Context) { var total uint64 countQuery := fmt.Sprintf("SELECT count() FROM w_user_access_logs %s", whereClause) if err := db.ChConn.QueryRow(c.Request.Context(), countQuery, args...).Scan(&total); err != nil { - c.JSON(http.StatusInternalServerError, response.Err("查询 ClickHouse 日志统计失败: "+err.Error())) + response.AbortWithError(c, http.StatusInternalServerError, "查询 ClickHouse 日志统计失败: "+err.Error()) return } if total == 0 { @@ -337,7 +339,7 @@ func GetAccessLogs(c *gin.Context) { // 5. 分页查询明细数据 list, err := fetchAccessLogDetails(c.Request.Context(), whereClause, args, pageSize, offset) if err != nil { - c.JSON(http.StatusInternalServerError, response.Err(err.Error())) + response.AbortWithError(c, http.StatusInternalServerError, err.Error()) return } @@ -388,7 +390,7 @@ type logsAnalyticsResponse struct { func GetLogsAnalytics(c *gin.Context) { // 1. 检查 ClickHouse 是否启用 if !config.Config.ClickHouse.Enabled || db.ChConn == nil { - c.JSON(http.StatusBadRequest, response.Err("ClickHouse 存储服务未启用,无法获取分析数据")) + response.AbortWithError(c, http.StatusBadRequest, "ClickHouse 存储服务未启用,无法获取分析数据") return } diff --git a/internal/common/response/response.go b/internal/common/response/response.go index f17b1770..14e32b0f 100644 --- a/internal/common/response/response.go +++ b/internal/common/response/response.go @@ -5,6 +5,8 @@ // Package response provides shared HTTP API response structures. package response +import "github.com/gin-gonic/gin" + // Response 通用响应体 type Response[T any] struct { ErrorMsg string `json:"error_msg"` @@ -18,6 +20,27 @@ type Any struct { Data interface{} `json:"data"` } +// APIError 统一的 API 业务错误类型,可被全局错误处理中间件捕获 +type APIError struct { + Code int + Msg string +} + +func (e *APIError) Error() string { + return e.Msg +} + +// NewError 实例化一个 APIError +func NewError(code int, msg string) *APIError { + return &APIError{Code: code, Msg: msg} +} + +// AbortWithError 将 API 错误挂载到 Gin Context 并中断执行流 +func AbortWithError(c *gin.Context, code int, msg string) { + _ = c.Error(NewError(code, msg)) + c.Abort() +} + // OK 构造成功响应 func OK[T any](data T) Response[T] { return Response[T]{Data: data} diff --git a/internal/router/middlewares.go b/internal/router/middlewares.go index a0f5f411..77f05633 100644 --- a/internal/router/middlewares.go +++ b/internal/router/middlewares.go @@ -7,11 +7,13 @@ package router import ( "context" + "errors" "net/http" "strconv" "strings" "time" + "github.com/Rain-kl/Wavelet/internal/common/response" "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/logger" @@ -103,3 +105,30 @@ func corsMiddleware() gin.HandlerFunc { c.Next() } } + +// errorHandlerMiddleware 捕获 c.Errors 并统一格式化为 JSON 返回给客户端,同时将其记录到 Span 异常中 +func errorHandlerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + if len(c.Errors) > 0 { + err := c.Errors.Last().Err + span := trace.SpanFromContext(c.Request.Context()) + + // 1. 如果有活跃的 Span,将错误信息记录到 Trace 中,并把 Span 状态置为 Error + if span.IsRecording() { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + // 2. 将错误转化为统一的 JSON 格式响应给客户端 + var apiErr *response.APIError + if errors.As(err, &apiErr) { + c.JSON(apiErr.Code, response.Err(apiErr.Msg)) + } else { + // 兜底策略:未知的系统级错误 + c.JSON(http.StatusInternalServerError, response.Err("内部系统错误")) + } + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 6e3ef722..b059c4a7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -83,7 +83,7 @@ func Serve() { r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) // 补充中间件 - r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware(), risk_control.RiskControlMiddleware()) + r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware(), risk_control.RiskControlMiddleware()) registerRoutes(r)