// Package errors 三层错误模型(业务错误 sentinel + 错误码分段)。 // // 本包定义全后端统一的业务错误类型 AppError 与一组"可复用"的 sentinel 错误。 // 分层职责(架构规格 §3.2): // - Repository:把 gorm.ErrRecordNotFound 等 ORM 原错转成 sentinel,不向上泄漏 ORM 原错; // - Service:只返回 sentinel(可附带 Cause 保留底层错误供日志),禁止把 gorm 原错直接上抛; // - Handler:用 *response.Fail 写出;非 *AppError 走 ErrInternal + 结构化日志。 // // 错误码分段(新接口统一;迁移期旧接口保留原 code): // - 1xxx 认证/权限;2xxx 参数/校验;3xxx 数据;4xxx 依赖;5xxx 系统。 // // 典型使用(Service 层): // // func (s *Service) Get(ctx context.Context, id int64) (*model.User, error) { // u, err := s.repo.Take(ctx, id) // if err != nil { // // Repository 已把 gorm.ErrRecordNotFound 转成 ErrNotFound;这里再组合 Cause 便于日志排障 // return nil, errors.WithCause(errors.ErrNotFound, err) // } // return u, nil // } package errors import "fmt" // AppError 业务错误(可预期、可被 Handler 映射为统一响应码)。 // // 字段: // - Code int:分段错误码(见包注释的 1xxx~5xxx 分段表)。 // - Msg string:面向用户的错误文案(脱敏,不含 SQL/堆栈/哈希)。 // - Cause error:底层原因(可选);用于日志排障,绝不进入响应体。 // - base *AppError(非导出):WithCause 时的"源 sentinel"回引用—— // 使 WithCause(ErrNotFound, gormErr) 仍能被 errors.Is(err, ErrNotFound) 命中 // (见 Unwrap 说明)。New(code,msg) 创建的自定义错误 base 为 nil。 // // 使用约束: // - 业务域通常直接复用本包提供的 sentinel(ErrNotFound 等); // - 需要自定义 code/msg 时用 New(code, msg); // - 需要保留底层原因时用 WithCause(sentinel, cause)。 type AppError struct { Code int Msg string Cause error // base 指向"被 WithCause 附挂底层原因的源 sentinel"(如 ErrNotFound)。 // 仅内部用于 Unwrap 的解包链,导出接口/JSON 均不感知。 base *AppError } // Error 实现 error 接口。 // // 入参:无(值接收在方法集上,调用者无需关心)。 // 出参: // - string:Cause 非 nil 时为 "Msg: Cause.Error()"(便于日志串联);Cause 为 nil 时仅返回 Msg。 // // 说明:Cause 参与字符串拼接仅服务于日志/调试;前端展示的文案是 Handler 单独取 Msg,不受 Cause 影响。 func (e *AppError) Error() string { if e.Cause != nil { return fmt.Sprintf("%s: %s", e.Msg, e.Cause.Error()) } return e.Msg } // Unwrap 暴露底层原因链,支持 errors.Is / errors.As 逐层解包(Go 1.20+ 多错误解包)。 // // 入参:无(指针接收在方法集上,调用者无需关心)。 // 出参: // - []error:解包链,按序含 Cause 与 base(源 sentinel);去重且过滤 nil 后返回。 // 空切片表示"无底层可解包"(既无 Cause 也无 base,如 New 创建的自定义错误)。 // // 关键设计(使 errors.Is 命中 WithCause 的源 sentinel): // WithCause(ErrNotFound, gormErr) 返回的 *AppError 是 ErrNotFound 的**副本**(Code/Msg 拷贝), // 与 ErrNotFound 指针不同。若 Unwrap 只回 Cause(gormErr),errors.Is(err, ErrNotFound) 会 // 走 err→gormErr→nil,永远找不到 ErrNotFound。故这里把 base(源 sentinel 指针)一并解出: // // err := WithCause(ErrNotFound, fmt.Errorf("sql: no rows")) // err.Unwrap() // [gormErr, ErrNotFound] // errors.Is(err, ErrNotFound) // true(命中 base) func (e *AppError) Unwrap() []error { // 去重:若 base 与 Cause 恰好是同一 *AppError(调用方把 sentinel 本身当 cause 传入),只保留一份。 var chain []error if e.Cause != nil { chain = append(chain, e.Cause) } if e.base != nil && e.base != e.Cause { chain = append(chain, e.base) } return chain } // New 创建一个新的 AppError(自定义 code 与 msg)。 // // 入参: // - code int:分段错误码(遵循 1xxx~5xxx 分段;传 200 视为误用,成功请用 response.OK)。 // - msg string:面向用户的错误文案(应已脱敏)。 // // 出参: // - *AppError:新建的业务错误;Cause 为 nil。 // // 异常场景: // - code 传 0 或负数:不校验(调用方责任),但建议单测断言 code 落在分段区间内。 // // 样例: // // return &errors.AppError via errors.New(3002, "该手机号已被绑定") func New(code int, msg string) *AppError { return &AppError{Code: code, Msg: msg} } // WithCause 为已有 sentinel 附加底层原因,返回新的 *AppError。 // // 入参: // - base *AppError:目标业务错误(如 ErrNotFound);为 nil 时返回 cause 包装成的 ErrInternal。 // - cause error:底层原因(ORM 原错/第三方错误);为 nil 时直接返回 base 的副本。 // // 出参: // - *AppError:Code/Msg 继承自 base,Cause 置为 cause,base 回引用指向入参 base // (不修改入参 base,避免共享污染;回引用使 errors.Is 能命中源 sentinel,见 Unwrap)。 // // 异常场景: // - base 为 nil:返回 ErrInternal 的副本并挂上 cause(base 回引用指向 ErrInternal,防御调用方传错)。 // // 样例: // // err := errors.WithCause(errors.ErrConflict, dbErr) // stderrors.Is(err, errors.ErrConflict) // true func WithCause(base *AppError, cause error) *AppError { if base == nil { return &AppError{Code: ErrInternal.Code, Msg: ErrInternal.Msg, Cause: cause, base: ErrInternal} } return &AppError{Code: base.Code, Msg: base.Msg, Cause: cause, base: base} } // 分段错误码常量(与 R.Code 对齐;新接口一律引用这些常量,禁止散落 magic number)。 const ( CodeOK = 200 CodeUnauth = 1001 // 未登录 CodeTokenExpired = 1002 // 登录态失效 CodeForbidden = 1003 // 无权限 CodeScopeViolation = 1004 // 作用域越界(跨机构/跨校区) CodeBadParam = 2001 // 参数缺失 CodeBadFormat = 2002 // 参数格式错误 CodeRuleConflict = 2003 // 业务规则冲突 CodeNotFound = 3001 // 记录不存在 CodeConflict = 3002 // 数据冲突 CodeUnique = 3003 // 唯一约束 CodeAgentDown = 4001 // Agent 不可达 CodePayCallback = 4002 // 支付回调失败 CodeThirdTimeout = 4003 // 第三方超时 CodeInternal = 5001 // 内部错误 CodeDBError = 5002 // 数据库异常 CodeThrottled = 5003 // 限流 ) // 预置 sentinel 错误(业务域直接复用,无需各自 new)。 // // 每个 sentinel 的语义: // - ErrUnauth:请求未携带有效登录态(缺 token / token 无效)。 // - ErrTokenExpired:登录态已过期(access 超 30min 且 refresh 也失效)。 // - ErrForbidden:已登录但无该操作权限(角色/权限点不匹配)。 // - ErrUnscoped:作用域越界——试图访问非本机构/非本校区的资源(多租户防线)。 // - ErrBadParam:入参缺失或格式非法(validator 校验失败的统一出口)。 // - ErrNotFound:目标记录不存在(Repository 把 gorm.ErrRecordNotFound 转成本 sentinel)。 // - ErrConflict:数据冲突(如唯一键撞车、状态机非法迁移)。 // - ErrAgentDown:依赖的 Python Agent 不可达(fire-and-forget 语义下仅记日志,主链路不阻断时用本码告警)。 // - ErrInternal:未分类内部错误(兜底,Handler 会把任何非 *AppError 降级到本码)。 // - ErrDBError:数据库异常(连接/事务/SQL 层,非"记录不存在")。 var ( ErrUnauth = &AppError{Code: CodeUnauth, Msg: "未登录"} ErrTokenExpired = &AppError{Code: CodeTokenExpired, Msg: "登录态失效"} ErrForbidden = &AppError{Code: CodeForbidden, Msg: "无权限"} ErrUnscoped = &AppError{Code: CodeScopeViolation, Msg: "作用域越界"} ErrBadParam = &AppError{Code: CodeBadParam, Msg: "参数错误"} ErrNotFound = &AppError{Code: CodeNotFound, Msg: "记录不存在"} ErrConflict = &AppError{Code: CodeConflict, Msg: "数据冲突"} ErrAgentDown = &AppError{Code: CodeAgentDown, Msg: "AI 服务暂不可用"} ErrInternal = &AppError{Code: CodeInternal, Msg: "系统繁忙"} ErrDBError = &AppError{Code: CodeDBError, Msg: "数据服务异常"} )