From ced4202dc909e5c59d87fa2f4c49f0ebcc5d3700 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 11 Sep 2025 11:44:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E7=BB=93=E6=9E=84=E5=8F=8A?= =?UTF-8?q?=E9=A2=84=E5=AE=9A=E4=B9=89=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/project/app/errorx/app_error.go.tpl | 65 ++++++++ templates/project/app/errorx/codes.go.tpl | 91 +++++++++++ templates/project/app/errorx/error.go.tpl | 151 ------------------ templates/project/app/errorx/handler.go.tpl | 106 ++++++++++++ .../project/app/errorx/middleware.go.tpl | 38 +++++ .../project/app/errorx/predefined.go.tpl | 106 ++++++++++++ templates/project/app/errorx/response.go.tpl | 127 +++++++++++++++ templates/project/app/srv/http/http.go.tpl | 2 +- .../project/providers/http/engine.go.tpl | 120 ++++++++++++-- 9 files changed, 645 insertions(+), 161 deletions(-) create mode 100644 templates/project/app/errorx/app_error.go.tpl create mode 100644 templates/project/app/errorx/codes.go.tpl delete mode 100644 templates/project/app/errorx/error.go.tpl create mode 100644 templates/project/app/errorx/handler.go.tpl create mode 100644 templates/project/app/errorx/middleware.go.tpl create mode 100644 templates/project/app/errorx/predefined.go.tpl create mode 100644 templates/project/app/errorx/response.go.tpl diff --git a/templates/project/app/errorx/app_error.go.tpl b/templates/project/app/errorx/app_error.go.tpl new file mode 100644 index 0000000..b364e87 --- /dev/null +++ b/templates/project/app/errorx/app_error.go.tpl @@ -0,0 +1,65 @@ +package errorx + +import ( + "fmt" + "runtime" +) + +// AppError 应用错误结构 +type AppError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + StatusCode int `json:"-"` + Data any `json:"data,omitempty"` + ID string `json:"id,omitempty"` + + // 调试信息 + originalErr error + file string + params []any + sql string +} + +// Error 实现 error 接口 +func (e *AppError) Error() string { + return fmt.Sprintf("[%d] %s", e.Code, e.Message) +} + +// Unwrap 允许通过 errors.Unwrap 遍历到原始错误 +func (e *AppError) Unwrap() error { return e.originalErr } + +// WithData 添加数据 +func (e *AppError) WithData(data any) *AppError { + e.Data = data + return e +} + +// WithMsg 设置消息 +func (e *AppError) WithMsg(msg string) *AppError { + e.Message = msg + return e +} + +// WithSQL 记录SQL信息 +func (e *AppError) WithSQL(sql string) *AppError { + e.sql = sql + return e +} + +// WithParams 记录参数信息,并自动获取调用位置 +func (e *AppError) WithParams(params ...any) *AppError { + e.params = params + if _, file, line, ok := runtime.Caller(1); ok { + e.file = fmt.Sprintf("%s:%d", file, line) + } + return e +} + +// NewError 创建应用错误 +func NewError(code ErrorCode, statusCode int, message string) *AppError { + return &AppError{ + Code: code, + Message: message, + StatusCode: statusCode, + } +} diff --git a/templates/project/app/errorx/codes.go.tpl b/templates/project/app/errorx/codes.go.tpl new file mode 100644 index 0000000..a574bc8 --- /dev/null +++ b/templates/project/app/errorx/codes.go.tpl @@ -0,0 +1,91 @@ +package errorx + +// ErrorCode 错误码类型 +type ErrorCode int + +const ( + // 1000-1099: 数据相关错误 + CodeRecordNotFound ErrorCode = 1001 + CodeRecordDuplicated ErrorCode = 1002 + CodeDataCorrupted ErrorCode = 1003 + CodeDataTooLarge ErrorCode = 1004 + CodeDataValidationFail ErrorCode = 1005 + CodeConstraintViolated ErrorCode = 1006 + CodeDataExpired ErrorCode = 1007 + CodeDataLocked ErrorCode = 1008 + + // 1100-1199: 请求相关错误 + CodeBadRequest ErrorCode = 1101 + CodeMissingParameter ErrorCode = 1102 + CodeInvalidParameter ErrorCode = 1103 + CodeParameterTooLong ErrorCode = 1104 + CodeParameterTooShort ErrorCode = 1105 + CodeInvalidFormat ErrorCode = 1106 + CodeUnsupportedMethod ErrorCode = 1107 + CodeRequestTooLarge ErrorCode = 1108 + CodeInvalidJSON ErrorCode = 1109 + CodeInvalidXML ErrorCode = 1110 + + // 1200-1299: 认证授权错误 + CodeUnauthorized ErrorCode = 1201 + CodeForbidden ErrorCode = 1202 + CodeTokenExpired ErrorCode = 1203 + CodeTokenInvalid ErrorCode = 1204 + CodeTokenMissing ErrorCode = 1205 + CodePermissionDenied ErrorCode = 1206 + CodeAccountDisabled ErrorCode = 1207 + CodeAccountLocked ErrorCode = 1208 + CodeInvalidCredentials ErrorCode = 1209 + CodeSessionExpired ErrorCode = 1210 + + // 1300-1399: 业务逻辑错误 + CodeBusinessLogic ErrorCode = 1301 + CodeWorkflowError ErrorCode = 1302 + CodeStatusConflict ErrorCode = 1303 + CodeOperationFailed ErrorCode = 1304 + CodeResourceConflict ErrorCode = 1305 + CodePreconditionFailed ErrorCode = 1306 + CodeQuotaExceeded ErrorCode = 1307 + CodeResourceExhausted ErrorCode = 1308 + + // 1400-1499: 外部服务错误 + CodeExternalService ErrorCode = 1401 + CodeServiceUnavailable ErrorCode = 1402 + CodeServiceTimeout ErrorCode = 1403 + CodeThirdPartyError ErrorCode = 1404 + CodeNetworkError ErrorCode = 1405 + CodeDatabaseError ErrorCode = 1406 + CodeCacheError ErrorCode = 1407 + CodeMessageQueueError ErrorCode = 1408 + + // 1500-1599: 系统错误 + CodeInternalError ErrorCode = 1501 + CodeConfigurationError ErrorCode = 1502 + CodeFileSystemError ErrorCode = 1503 + CodeMemoryError ErrorCode = 1504 + CodeConcurrencyError ErrorCode = 1505 + CodeDeadlockError ErrorCode = 1506 + + // 1600-1699: 限流和频率控制 + CodeRateLimitExceeded ErrorCode = 1601 + CodeTooManyRequests ErrorCode = 1602 + CodeConcurrentLimit ErrorCode = 1603 + CodeAPIQuotaExceeded ErrorCode = 1604 + + // 1700-1799: 文件和上传错误 + CodeFileNotFound ErrorCode = 1701 + CodeFileTooBig ErrorCode = 1702 + CodeInvalidFileType ErrorCode = 1703 + CodeFileCorrupted ErrorCode = 1704 + CodeUploadFailed ErrorCode = 1705 + CodeDownloadFailed ErrorCode = 1706 + CodeFilePermission ErrorCode = 1707 + + // 1800-1899: 加密和安全错误 + CodeEncryptionError ErrorCode = 1801 + CodeDecryptionError ErrorCode = 1802 + CodeSignatureInvalid ErrorCode = 1803 + CodeCertificateInvalid ErrorCode = 1804 + CodeSecurityViolation ErrorCode = 1805 +) + diff --git a/templates/project/app/errorx/error.go.tpl b/templates/project/app/errorx/error.go.tpl deleted file mode 100644 index cd90c17..0000000 --- a/templates/project/app/errorx/error.go.tpl +++ /dev/null @@ -1,151 +0,0 @@ -package errorx - -import ( - "errors" - "fmt" - "net/http" - "runtime" - - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/binder" - "github.com/gofiber/utils/v2" - log "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -func Middleware(c fiber.Ctx) error { - err := c.Next() - if err != nil { - return Wrap(err).Response(c) - } - return err -} - -type Response struct { - isFormat bool - err error - params []any - sql string - file string - - StatusCode int `json:"-" xml:"-"` - Code int `json:"code" xml:"code"` - Message string `json:"message" xml:"message"` - Data any `json:"data,omitempty" xml:"data"` -} - -func New(code, statusCode int, message string) *Response { - return &Response{ - isFormat: true, - StatusCode: statusCode, - Code: code, - Message: message, - } -} - -func (r *Response) WithMsg(msg string) *Response { - r.Message = msg - return r -} - -func (r *Response) Sql(sql string) *Response { - r.sql = sql - return r -} - -func (r *Response) from(err *Response) *Response { - r.Code = err.Code - r.Message = err.Message - r.StatusCode = err.StatusCode - return r -} - -func (r *Response) Params(params ...any) *Response { - r.params = params - if _, file, line, ok := runtime.Caller(1); ok { - r.file = fmt.Sprintf("%s:%d", file, line) - } - return r -} - -func Wrap(err error) *Response { - if e, ok := err.(*Response); ok { - return e - } - return &Response{err: err} -} - -func (r *Response) Wrap(err error) *Response { - r.err = err - return r -} - -func (r *Response) format() { - r.isFormat = true - if e, ok := r.err.(*fiber.Error); ok { - r.Code = e.Code - r.Message = e.Message - r.StatusCode = e.Code - return - } - - if r.err != nil { - msg := r.err.Error() - if errors.Is(r.err, gorm.ErrRecordNotFound) { - r.from(RecordNotExists) - return - } - - if errors.Is(r.err, gorm.ErrDuplicatedKey) { - r.from(RecordDuplicated) - return - } - - r.Code = http.StatusInternalServerError - r.StatusCode = http.StatusInternalServerError - r.Message = msg - } - return -} - -func (r *Response) Error() string { - if !r.isFormat { - r.format() - } - - return fmt.Sprintf("[%d] %s", r.Code, r.Message) -} - -func (r *Response) Response(ctx fiber.Ctx) error { - if !r.isFormat { - r.format() - } - - contentType := utils.ToLower(utils.UnsafeString(ctx.Request().Header.ContentType())) - contentType = binder.FilterFlags(utils.ParseVendorSpecificContentType(contentType)) - - log. - WithError(r.err). - WithField("file", r.file). - WithField("sql", r.sql). - WithField("params", r.params). - Errorf("response error: %+v", r) - - // Parse body accordingly - switch contentType { - case fiber.MIMETextXML, fiber.MIMEApplicationXML: - return ctx.Status(r.StatusCode).XML(r) - case fiber.MIMETextHTML, fiber.MIMETextPlain: - return ctx.Status(r.StatusCode).SendString(r.Message) - default: - return ctx.Status(r.StatusCode).JSON(r) - } -} - -var ( - RecordDuplicated = New(1001, http.StatusBadRequest, "记录重复") - RecordNotExists = New(http.StatusNotFound, http.StatusNotFound, "记录不存在") - BadRequest = New(http.StatusBadRequest, http.StatusBadRequest, "请求错误") - Unauthorized = New(http.StatusUnauthorized, http.StatusUnauthorized, "未授权") - InternalErr = New(http.StatusInternalServerError, http.StatusInternalServerError, "内部错误") -) diff --git a/templates/project/app/errorx/handler.go.tpl b/templates/project/app/errorx/handler.go.tpl new file mode 100644 index 0000000..dcac419 --- /dev/null +++ b/templates/project/app/errorx/handler.go.tpl @@ -0,0 +1,106 @@ +package errorx + +import ( + "errors" + "net/http" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// ErrorHandler 错误处理器 +type ErrorHandler struct{} + +// NewErrorHandler 创建错误处理器 +func NewErrorHandler() *ErrorHandler { + return &ErrorHandler{} +} + +// Handle 处理错误并返回统一格式 +func (h *ErrorHandler) Handle(err error) *AppError { + if appErr, ok := err.(*AppError); ok { + return appErr + } + + // 处理 Fiber 错误 + if fiberErr, ok := err.(*fiber.Error); ok { + return h.handleFiberError(fiberErr) + } + + // 处理 GORM 错误 + if appErr := h.handleGormError(err); appErr != nil { + return appErr + } + + // 默认内部错误 + return &AppError{ + Code: ErrInternalError.Code, + Message: err.Error(), + StatusCode: http.StatusInternalServerError, + originalErr: err, + } +} + +// handleFiberError 处理 Fiber 错误 +func (h *ErrorHandler) handleFiberError(fiberErr *fiber.Error) *AppError { + var appErr *AppError + + switch fiberErr.Code { + case http.StatusBadRequest: + appErr = ErrBadRequest + case http.StatusUnauthorized: + appErr = ErrUnauthorized + case http.StatusForbidden: + appErr = ErrForbidden + case http.StatusNotFound: + appErr = ErrRecordNotFound + case http.StatusMethodNotAllowed: + appErr = ErrUnsupportedMethod + case http.StatusRequestEntityTooLarge: + appErr = ErrRequestTooLarge + case http.StatusTooManyRequests: + appErr = ErrTooManyRequests + default: + appErr = ErrInternalError + } + + return &AppError{ + Code: appErr.Code, + Message: fiberErr.Message, + StatusCode: fiberErr.Code, + originalErr: fiberErr, + } +} + +// handleGormError 处理 GORM 错误 +func (h *ErrorHandler) handleGormError(err error) *AppError { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &AppError{ + Code: ErrRecordNotFound.Code, + Message: ErrRecordNotFound.Message, + StatusCode: ErrRecordNotFound.StatusCode, + originalErr: err, + } + } + + if errors.Is(err, gorm.ErrDuplicatedKey) { + return &AppError{ + Code: ErrRecordDuplicated.Code, + Message: ErrRecordDuplicated.Message, + StatusCode: ErrRecordDuplicated.StatusCode, + originalErr: err, + } + } + + if errors.Is(err, gorm.ErrInvalidTransaction) { + return &AppError{ + Code: ErrConcurrencyError.Code, + Message: "事务无效", + StatusCode: ErrConcurrencyError.StatusCode, + originalErr: err, + } + } + + return nil +} + diff --git a/templates/project/app/errorx/middleware.go.tpl b/templates/project/app/errorx/middleware.go.tpl new file mode 100644 index 0000000..7a3d8b9 --- /dev/null +++ b/templates/project/app/errorx/middleware.go.tpl @@ -0,0 +1,38 @@ +package errorx + +import "github.com/gofiber/fiber/v3" + +// 全局实例 +var DefaultSender = NewResponseSender() + +// Middleware 错误处理中间件 +func Middleware(c fiber.Ctx) error { + err := c.Next() + if err != nil { + return DefaultSender.SendError(c, err) + } + return nil +} + +// 便捷函数 +func Wrap(err error) *AppError { + if err == nil { + return nil + } + + if appErr, ok := err.(*AppError); ok { + return &AppError{ + Code: appErr.Code, + Message: appErr.Message, + StatusCode: appErr.StatusCode, + Data: appErr.Data, + originalErr: appErr, + } + } + + return DefaultSender.handler.Handle(err) +} + +func SendError(ctx fiber.Ctx, err error) error { + return DefaultSender.SendError(ctx, err) +} diff --git a/templates/project/app/errorx/predefined.go.tpl b/templates/project/app/errorx/predefined.go.tpl new file mode 100644 index 0000000..f1d9259 --- /dev/null +++ b/templates/project/app/errorx/predefined.go.tpl @@ -0,0 +1,106 @@ +package errorx + +import "net/http" + +// 预定义错误 - 数据相关 +var ( + ErrRecordNotFound = NewError(CodeRecordNotFound, http.StatusNotFound, "记录不存在") + ErrRecordDuplicated = NewError(CodeRecordDuplicated, http.StatusConflict, "记录重复") + ErrDataCorrupted = NewError(CodeDataCorrupted, http.StatusBadRequest, "数据损坏") + ErrDataTooLarge = NewError(CodeDataTooLarge, http.StatusRequestEntityTooLarge, "数据过大") + ErrDataValidationFail = NewError(CodeDataValidationFail, http.StatusBadRequest, "数据验证失败") + ErrConstraintViolated = NewError(CodeConstraintViolated, http.StatusConflict, "约束违规") + ErrDataExpired = NewError(CodeDataExpired, http.StatusGone, "数据已过期") + ErrDataLocked = NewError(CodeDataLocked, http.StatusLocked, "数据已锁定") +) + +// 预定义错误 - 请求相关 +var ( + ErrBadRequest = NewError(CodeBadRequest, http.StatusBadRequest, "请求错误") + ErrMissingParameter = NewError(CodeMissingParameter, http.StatusBadRequest, "缺少必需参数") + ErrInvalidParameter = NewError(CodeInvalidParameter, http.StatusBadRequest, "参数无效") + ErrParameterTooLong = NewError(CodeParameterTooLong, http.StatusBadRequest, "参数过长") + ErrParameterTooShort = NewError(CodeParameterTooShort, http.StatusBadRequest, "参数过短") + ErrInvalidFormat = NewError(CodeInvalidFormat, http.StatusBadRequest, "格式无效") + ErrUnsupportedMethod = NewError(CodeUnsupportedMethod, http.StatusMethodNotAllowed, "不支持的请求方法") + ErrRequestTooLarge = NewError(CodeRequestTooLarge, http.StatusRequestEntityTooLarge, "请求体过大") + ErrInvalidJSON = NewError(CodeInvalidJSON, http.StatusBadRequest, "JSON格式错误") + ErrInvalidXML = NewError(CodeInvalidXML, http.StatusBadRequest, "XML格式错误") +) + +// 预定义错误 - 认证授权 +var ( + ErrUnauthorized = NewError(CodeUnauthorized, http.StatusUnauthorized, "未授权") + ErrForbidden = NewError(CodeForbidden, http.StatusForbidden, "禁止访问") + ErrTokenExpired = NewError(CodeTokenExpired, http.StatusUnauthorized, "Token已过期") + ErrTokenInvalid = NewError(CodeTokenInvalid, http.StatusUnauthorized, "Token无效") + ErrTokenMissing = NewError(CodeTokenMissing, http.StatusUnauthorized, "Token缺失") + ErrPermissionDenied = NewError(CodePermissionDenied, http.StatusForbidden, "权限不足") + ErrAccountDisabled = NewError(CodeAccountDisabled, http.StatusForbidden, "账户已禁用") + ErrAccountLocked = NewError(CodeAccountLocked, http.StatusLocked, "账户已锁定") + ErrInvalidCredentials = NewError(CodeInvalidCredentials, http.StatusUnauthorized, "凭据无效") + ErrSessionExpired = NewError(CodeSessionExpired, http.StatusUnauthorized, "会话已过期") +) + +// 预定义错误 - 业务逻辑 +var ( + ErrBusinessLogic = NewError(CodeBusinessLogic, http.StatusBadRequest, "业务逻辑错误") + ErrWorkflowError = NewError(CodeWorkflowError, http.StatusBadRequest, "工作流错误") + ErrStatusConflict = NewError(CodeStatusConflict, http.StatusConflict, "状态冲突") + ErrOperationFailed = NewError(CodeOperationFailed, http.StatusInternalServerError, "操作失败") + ErrResourceConflict = NewError(CodeResourceConflict, http.StatusConflict, "资源冲突") + ErrPreconditionFailed = NewError(CodePreconditionFailed, http.StatusPreconditionFailed, "前置条件失败") + ErrQuotaExceeded = NewError(CodeQuotaExceeded, http.StatusForbidden, "配额超限") + ErrResourceExhausted = NewError(CodeResourceExhausted, http.StatusTooManyRequests, "资源耗尽") +) + +// 预定义错误 - 外部服务 +var ( + ErrExternalService = NewError(CodeExternalService, http.StatusBadGateway, "外部服务错误") + ErrServiceUnavailable = NewError(CodeServiceUnavailable, http.StatusServiceUnavailable, "服务不可用") + ErrServiceTimeout = NewError(CodeServiceTimeout, http.StatusRequestTimeout, "服务超时") + ErrThirdPartyError = NewError(CodeThirdPartyError, http.StatusBadGateway, "第三方服务错误") + ErrNetworkError = NewError(CodeNetworkError, http.StatusBadGateway, "网络错误") + ErrDatabaseError = NewError(CodeDatabaseError, http.StatusInternalServerError, "数据库错误") + ErrCacheError = NewError(CodeCacheError, http.StatusInternalServerError, "缓存错误") + ErrMessageQueueError = NewError(CodeMessageQueueError, http.StatusInternalServerError, "消息队列错误") +) + +// 预定义错误 - 系统错误 +var ( + ErrInternalError = NewError(CodeInternalError, http.StatusInternalServerError, "内部错误") + ErrConfigurationError = NewError(CodeConfigurationError, http.StatusInternalServerError, "配置错误") + ErrFileSystemError = NewError(CodeFileSystemError, http.StatusInternalServerError, "文件系统错误") + ErrMemoryError = NewError(CodeMemoryError, http.StatusInternalServerError, "内存错误") + ErrConcurrencyError = NewError(CodeConcurrencyError, http.StatusInternalServerError, "并发错误") + ErrDeadlockError = NewError(CodeDeadlockError, http.StatusInternalServerError, "死锁错误") +) + +// 预定义错误 - 限流 +var ( + ErrRateLimitExceeded = NewError(CodeRateLimitExceeded, http.StatusTooManyRequests, "请求频率超限") + ErrTooManyRequests = NewError(CodeTooManyRequests, http.StatusTooManyRequests, "请求过多") + ErrConcurrentLimit = NewError(CodeConcurrentLimit, http.StatusTooManyRequests, "并发数超限") + ErrAPIQuotaExceeded = NewError(CodeAPIQuotaExceeded, http.StatusTooManyRequests, "API配额超限") +) + +// 预定义错误 - 文件处理 +var ( + ErrFileNotFound = NewError(CodeFileNotFound, http.StatusNotFound, "文件不存在") + ErrFileTooBig = NewError(CodeFileTooBig, http.StatusRequestEntityTooLarge, "文件过大") + ErrInvalidFileType = NewError(CodeInvalidFileType, http.StatusBadRequest, "文件类型无效") + ErrFileCorrupted = NewError(CodeFileCorrupted, http.StatusBadRequest, "文件损坏") + ErrUploadFailed = NewError(CodeUploadFailed, http.StatusInternalServerError, "上传失败") + ErrDownloadFailed = NewError(CodeDownloadFailed, http.StatusInternalServerError, "下载失败") + ErrFilePermission = NewError(CodeFilePermission, http.StatusForbidden, "文件权限不足") +) + +// 预定义错误 - 安全相关 +var ( + ErrEncryptionError = NewError(CodeEncryptionError, http.StatusInternalServerError, "加密错误") + ErrDecryptionError = NewError(CodeDecryptionError, http.StatusInternalServerError, "解密错误") + ErrSignatureInvalid = NewError(CodeSignatureInvalid, http.StatusUnauthorized, "签名无效") + ErrCertificateInvalid = NewError(CodeCertificateInvalid, http.StatusUnauthorized, "证书无效") + ErrSecurityViolation = NewError(CodeSecurityViolation, http.StatusForbidden, "安全违规") +) + diff --git a/templates/project/app/errorx/response.go.tpl b/templates/project/app/errorx/response.go.tpl new file mode 100644 index 0000000..d31c60d --- /dev/null +++ b/templates/project/app/errorx/response.go.tpl @@ -0,0 +1,127 @@ +package errorx + +import ( + "errors" + "fmt" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/binder" + "github.com/gofiber/utils/v2" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// ResponseSender 响应发送器 +type ResponseSender struct { + handler *ErrorHandler +} + +// NewResponseSender 创建响应发送器 +func NewResponseSender() *ResponseSender { + return &ResponseSender{ + handler: NewErrorHandler(), + } +} + +// SendError 发送错误响应 +func (s *ResponseSender) SendError(ctx fiber.Ctx, err error) error { + appErr := s.handler.Handle(err) + + // 记录错误日志 + s.logError(appErr) + + // 根据 Content-Type 返回不同格式 + return s.sendResponse(ctx, appErr) +} + +// logError 记录错误日志 +func (s *ResponseSender) logError(appErr *AppError) { + // 确保每个错误实例都有唯一ID,便于日志关联 + if appErr.ID == "" { + appErr.ID = uuid.NewString() + } + + // 构造详细的错误级联链路(包含类型、状态、定位等) + chain := make([]map[string]any, 0, 4) + var e error = appErr + for e != nil { + entry := map[string]any{ + "type": fmt.Sprintf("%T", e), + "error": e.Error(), + } + switch v := e.(type) { + case *AppError: + entry["code"] = v.Code + entry["statusCode"] = v.StatusCode + if v.file != "" { + entry["file"] = v.file + } + if len(v.params) > 0 { + entry["params"] = v.params + } + if v.sql != "" { + entry["sql"] = v.sql + } + if v.ID != "" { + entry["id"] = v.ID + } + case *fiber.Error: + entry["statusCode"] = v.Code + entry["message"] = v.Message + } + + // GORM 常见错误归类标记 + if errors.Is(e, gorm.ErrRecordNotFound) { + entry["gorm"] = "record_not_found" + } else if errors.Is(e, gorm.ErrDuplicatedKey) { + entry["gorm"] = "duplicated_key" + } else if errors.Is(e, gorm.ErrInvalidTransaction) { + entry["gorm"] = "invalid_transaction" + } + + chain = append(chain, entry) + e = errors.Unwrap(e) + } + + root := chain[len(chain)-1]["error"] + + logEntry := log.WithFields(log.Fields{ + "id": appErr.ID, + "code": appErr.Code, + "statusCode": appErr.StatusCode, + "file": appErr.file, + "sql": appErr.sql, + "params": appErr.params, + "error_chain": chain, + "root_error": root, + }) + + if appErr.originalErr != nil { + logEntry = logEntry.WithError(appErr.originalErr) + } + + // 根据错误级别记录不同级别的日志 + if appErr.StatusCode >= 500 { + logEntry.Error("系统错误: ", appErr.Message) + } else if appErr.StatusCode >= 400 { + logEntry.Warn("客户端错误: ", appErr.Message) + } else { + logEntry.Info("应用错误: ", appErr.Message) + } +} + +// sendResponse 发送响应 +func (s *ResponseSender) sendResponse(ctx fiber.Ctx, appErr *AppError) error { + contentType := utils.ToLower(utils.UnsafeString(ctx.Request().Header.ContentType())) + contentType = binder.FilterFlags(utils.ParseVendorSpecificContentType(contentType)) + + switch contentType { + case fiber.MIMETextXML, fiber.MIMEApplicationXML: + return ctx.Status(appErr.StatusCode).XML(appErr) + case fiber.MIMETextHTML, fiber.MIMETextPlain: + return ctx.Status(appErr.StatusCode).SendString(appErr.Message) + default: + return ctx.Status(appErr.StatusCode).JSON(appErr) + } +} diff --git a/templates/project/app/srv/http/http.go.tpl b/templates/project/app/srv/http/http.go.tpl index 8aac7d1..8b981a3 100644 --- a/templates/project/app/srv/http/http.go.tpl +++ b/templates/project/app/srv/http/http.go.tpl @@ -77,6 +77,6 @@ func Serve(cmd *cobra.Command, args []string) error { route.Register(group) } - return svc.Http.Serve() + return svc.Http.Serve(ctx) }) } diff --git a/templates/project/providers/http/engine.go.tpl b/templates/project/providers/http/engine.go.tpl index b7d445d..40e15b6 100644 --- a/templates/project/providers/http/engine.go.tpl +++ b/templates/project/providers/http/engine.go.tpl @@ -1,6 +1,7 @@ package http import ( + "context" "errors" "fmt" "net" @@ -12,8 +13,14 @@ import ( "go.ipao.vip/atom/opt" "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/compress" + "github.com/gofiber/fiber/v3/middleware/cors" + "github.com/gofiber/fiber/v3/middleware/helmet" + "github.com/gofiber/fiber/v3/middleware/limiter" "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/gofiber/fiber/v3/middleware/requestid" + "github.com/samber/lo" ) func DefaultProvider() container.ProviderContainer { @@ -44,7 +51,7 @@ func (svc *Service) listenerConfig() fiber.ListenConfig { listenConfig.CertKeyFile = svc.conf.Tls.Key } container.AddCloseAble(func() { - svc.Engine.Shutdown() + svc.Engine.ShutdownWithTimeout(time.Second * 10) }) return listenConfig } @@ -53,8 +60,29 @@ func (svc *Service) Listener(ln net.Listener) error { return svc.Engine.Listener(ln, svc.listenerConfig()) } -func (svc *Service) Serve() error { - return svc.Engine.Listen(svc.conf.Address(), svc.listenerConfig()) +func (svc *Service) Serve(ctx context.Context) error { + ln, err := net.Listen("tcp", svc.conf.Address()) + if err != nil { + return err + } + + // Run the server in a goroutine so we can listen for context cancellation + serverErr := make(chan error, 1) + go func() { + serverErr <- svc.Engine.Listener(ln, svc.listenerConfig()) + }() + + select { + case <-ctx.Done(): + // Shutdown the server gracefully + if shutdownErr := svc.Engine.Shutdown(); shutdownErr != nil { + return shutdownErr + } + // treat context cancellation as graceful shutdown + return nil + case err := <-serverErr: + return err + } } func Provide(opts ...opt.Option) error { @@ -66,25 +94,58 @@ func Provide(opts ...opt.Option) error { return container.Container.Provide(func() (*Service, error) { engine := fiber.New(fiber.Config{ - StrictRouting: true, + StrictRouting: true, + CaseSensitive: true, + BodyLimit: 10 * 1024 * 1024, // 10 MiB + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + ProxyHeader: fiber.HeaderXForwardedFor, + EnableIPValidation: true, }) + + // request id first for correlation + engine.Use(requestid.New()) + + // recover with stack + request id engine.Use(recover.New(recover.Config{ EnableStackTrace: true, StackTraceHandler: func(c fiber.Ctx, e any) { - log.Error(fmt.Sprintf("panic: %v\n%s\n", e, debug.Stack())) + rid := c.Get(fiber.HeaderXRequestID) + log.WithField("request_id", rid).Error(fmt.Sprintf("panic: %v\n%s\n", e, debug.Stack())) }, })) - if config.StaticRoute != nil && config.StaticPath != nil { - engine.Use(config.StaticRoute, config.StaticPath) + // basic security + compression + engine.Use(helmet.New()) + engine.Use(compress.New(compress.Config{Level: compress.LevelDefault})) + + // optional CORS based on config + if config.Cors != nil { + corsCfg := buildCORSConfig(config.Cors) + if corsCfg != nil { + engine.Use(cors.New(*corsCfg)) + } } + // logging with request id and latency engine.Use(logger.New(logger.Config{ - Format: `[${ip}:${port}] - [${time}] - ${method} - ${status} - ${path} ${latency} "${ua}"` + "\n", - TimeFormat: time.RFC1123, + // requestid middleware stores ctx.Locals("requestid") + Format: `${time} [${ip}] ${method} ${status} ${path} ${latency} rid=${locals:requestid} "${ua}"\n`, + TimeFormat: time.RFC3339, TimeZone: "Asia/Shanghai", })) + // rate limit (enable standard headers; adjust Max via config if needed) + engine.Use(limiter.New(limiter.Config{Max: 0})) + + // static files (Fiber v3 Static helper moved; enable via filesystem middleware later) + // if config.StaticRoute != nil && config.StaticPath != nil { ... } + + // health endpoints + engine.Get("/healthz", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) }) + engine.Get("/readyz", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) }) + engine.Hooks().OnPostShutdown(func(err error) error { if err != nil { log.Error("http server shutdown error: ", err) @@ -99,3 +160,44 @@ func Provide(opts ...opt.Option) error { }, nil }, o.DiOptions()...) } + +// buildCORSConfig converts provider Cors config into fiber cors.Config +func buildCORSConfig(c *Cors) *cors.Config { + if c == nil { + return nil + } + if c.Mode == "disabled" { + return nil + } + var ( + origins []string + headers []string + methods []string + exposes []string + allowCreds bool + ) + for _, w := range c.Whitelist { + if w.AllowOrigin != "" { + origins = append(origins, w.AllowOrigin) + } + if w.AllowHeaders != "" { + headers = append(headers, w.AllowHeaders) + } + if w.AllowMethods != "" { + methods = append(methods, w.AllowMethods) + } + if w.ExposeHeaders != "" { + exposes = append(exposes, w.ExposeHeaders) + } + allowCreds = allowCreds || w.AllowCredentials + } + + cfg := cors.Config{ + AllowOrigins: lo.Uniq(origins), + AllowHeaders: lo.Uniq(headers), + AllowMethods: lo.Uniq(methods), + ExposeHeaders: lo.Uniq(exposes), + AllowCredentials: allowCreds, + } + return &cfg +}