mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
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
This commit is contained in:
@@ -124,7 +124,7 @@ Handler 规范:
|
|||||||
- Handler 命名为 动词 + 名词,例如 `ListUsers`。
|
- Handler 命名为 动词 + 名词,例如 `ListUsers`。
|
||||||
- 使用 `ShouldBindQuery` 或 `ShouldBindJSON` 进行绑定。
|
- 使用 `ShouldBindQuery` 或 `ShouldBindJSON` 进行绑定。
|
||||||
- 成功时统一通过导入 `"github.com/Rain-kl/Wavelet/internal/common/response"` 使用 `response.OK(data)` 或 `response.OKNil()` 返回。
|
- 成功时统一通过导入 `"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": ... }`。
|
- API 响应的外层结构必须为 `{ "error_msg": "", "data": ... }`。
|
||||||
- 分页响应在 `data` 下使用 `{ "total": 0, "results": [] }`。
|
- 分页响应在 `data` 下使用 `{ "total": 0, "results": [] }`。
|
||||||
- 每个 HTTP API 都需要有完整的 Swagger 注释;在 API 变更后运行 `make swagger`。
|
- 每个 HTTP API 都需要有完整的 Swagger 注释;在 API 变更后运行 `make swagger`。
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
// Package logs 提供日志查询与分析功能
|
// Package logs 提供日志查询与分析功能
|
||||||
package logs
|
package logs
|
||||||
|
|
||||||
import ("context"
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,7 +22,8 @@ import ("context"
|
|||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/common/response")
|
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLimit = 200
|
defaultLimit = 200
|
||||||
@@ -57,7 +59,7 @@ func GetLogs(c *gin.Context) {
|
|||||||
|
|
||||||
var cursor, limit int
|
var cursor, limit int
|
||||||
if _, err := parsePositiveInt(cursorStr, &cursor); err != nil {
|
if _, err := parsePositiveInt(cursorStr, &cursor); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, response.Err(admin.InvalidCursorParam))
|
response.AbortWithError(c, http.StatusBadRequest, admin.InvalidCursorParam)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 {
|
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) {
|
func GetAccessLogs(c *gin.Context) {
|
||||||
// 1. 检查 ClickHouse 是否启用
|
// 1. 检查 ClickHouse 是否启用
|
||||||
if !config.Config.ClickHouse.Enabled || db.ChConn == nil {
|
if !config.Config.ClickHouse.Enabled || db.ChConn == nil {
|
||||||
c.JSON(http.StatusBadRequest, response.Err("ClickHouse 存储服务未启用,无法检索访问日志"))
|
response.AbortWithError(c, http.StatusBadRequest, "ClickHouse 存储服务未启用,无法检索访问日志")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +311,7 @@ func GetAccessLogs(c *gin.Context) {
|
|||||||
// 3. 构建过滤条件
|
// 3. 构建过滤条件
|
||||||
conditions, args, userIDs, err := buildAccessLogFilters(c.Request.Context(), c)
|
conditions, args, userIDs, err := buildAccessLogFilters(c.Request.Context(), c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
response.AbortWithError(c, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if userIDs != nil && len(userIDs) == 0 {
|
if userIDs != nil && len(userIDs) == 0 {
|
||||||
@@ -326,7 +328,7 @@ func GetAccessLogs(c *gin.Context) {
|
|||||||
var total uint64
|
var total uint64
|
||||||
countQuery := fmt.Sprintf("SELECT count() FROM w_user_access_logs %s", whereClause)
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
@@ -337,7 +339,7 @@ func GetAccessLogs(c *gin.Context) {
|
|||||||
// 5. 分页查询明细数据
|
// 5. 分页查询明细数据
|
||||||
list, err := fetchAccessLogDetails(c.Request.Context(), whereClause, args, pageSize, offset)
|
list, err := fetchAccessLogDetails(c.Request.Context(), whereClause, args, pageSize, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
response.AbortWithError(c, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +390,7 @@ type logsAnalyticsResponse struct {
|
|||||||
func GetLogsAnalytics(c *gin.Context) {
|
func GetLogsAnalytics(c *gin.Context) {
|
||||||
// 1. 检查 ClickHouse 是否启用
|
// 1. 检查 ClickHouse 是否启用
|
||||||
if !config.Config.ClickHouse.Enabled || db.ChConn == nil {
|
if !config.Config.ClickHouse.Enabled || db.ChConn == nil {
|
||||||
c.JSON(http.StatusBadRequest, response.Err("ClickHouse 存储服务未启用,无法获取分析数据"))
|
response.AbortWithError(c, http.StatusBadRequest, "ClickHouse 存储服务未启用,无法获取分析数据")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// Package response provides shared HTTP API response structures.
|
// Package response provides shared HTTP API response structures.
|
||||||
package response
|
package response
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
// Response 通用响应体
|
// Response 通用响应体
|
||||||
type Response[T any] struct {
|
type Response[T any] struct {
|
||||||
ErrorMsg string `json:"error_msg"`
|
ErrorMsg string `json:"error_msg"`
|
||||||
@@ -18,6 +20,27 @@ type Any struct {
|
|||||||
Data interface{} `json:"data"`
|
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 构造成功响应
|
// OK 构造成功响应
|
||||||
func OK[T any](data T) Response[T] {
|
func OK[T any](data T) Response[T] {
|
||||||
return Response[T]{Data: data}
|
return Response[T]{Data: data}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||||
"github.com/Rain-kl/Wavelet/internal/config"
|
"github.com/Rain-kl/Wavelet/internal/config"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
@@ -103,3 +105,30 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
c.Next()
|
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("内部系统错误"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func Serve() {
|
|||||||
r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore))
|
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)
|
registerRoutes(r)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user