errors.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. // Package errors 三层错误模型(业务错误 sentinel + 错误码分段)。
  2. //
  3. // 本包定义全后端统一的业务错误类型 AppError 与一组"可复用"的 sentinel 错误。
  4. // 分层职责(架构规格 §3.2):
  5. // - Repository:把 gorm.ErrRecordNotFound 等 ORM 原错转成 sentinel,不向上泄漏 ORM 原错;
  6. // - Service:只返回 sentinel(可附带 Cause 保留底层错误供日志),禁止把 gorm 原错直接上抛;
  7. // - Handler:用 *response.Fail 写出;非 *AppError 走 ErrInternal + 结构化日志。
  8. //
  9. // 错误码分段(新接口统一;迁移期旧接口保留原 code):
  10. // - 1xxx 认证/权限;2xxx 参数/校验;3xxx 数据;4xxx 依赖;5xxx 系统。
  11. //
  12. // 典型使用(Service 层):
  13. //
  14. // func (s *Service) Get(ctx context.Context, id int64) (*model.User, error) {
  15. // u, err := s.repo.Take(ctx, id)
  16. // if err != nil {
  17. // // Repository 已把 gorm.ErrRecordNotFound 转成 ErrNotFound;这里再组合 Cause 便于日志排障
  18. // return nil, errors.WithCause(errors.ErrNotFound, err)
  19. // }
  20. // return u, nil
  21. // }
  22. package errors
  23. import "fmt"
  24. // AppError 业务错误(可预期、可被 Handler 映射为统一响应码)。
  25. //
  26. // 字段:
  27. // - Code int:分段错误码(见包注释的 1xxx~5xxx 分段表)。
  28. // - Msg string:面向用户的错误文案(脱敏,不含 SQL/堆栈/哈希)。
  29. // - Cause error:底层原因(可选);用于日志排障,绝不进入响应体。
  30. // - base *AppError(非导出):WithCause 时的"源 sentinel"回引用——
  31. // 使 WithCause(ErrNotFound, gormErr) 仍能被 errors.Is(err, ErrNotFound) 命中
  32. // (见 Unwrap 说明)。New(code,msg) 创建的自定义错误 base 为 nil。
  33. //
  34. // 使用约束:
  35. // - 业务域通常直接复用本包提供的 sentinel(ErrNotFound 等);
  36. // - 需要自定义 code/msg 时用 New(code, msg);
  37. // - 需要保留底层原因时用 WithCause(sentinel, cause)。
  38. type AppError struct {
  39. Code int
  40. // Msg 该错误码的**默认对外文案**(来自 sentinel,全码唯一)。
  41. Msg string
  42. // PublicMsg 本次错误的**对外文案覆盖**(空 = 用 Msg)。
  43. //
  44. // 为什么需要它(而不是直接读 Cause):`Cause` 的定位是**诊断信息**——
  45. // 里面会塞 SQL 片段、内部分层名、甚至 gorm 原始错误,**绝不能进响应体**。
  46. // 但有一类错误(如"额度不得超过上级")必须把**具体数字**告诉操作人,
  47. // 否则用户只看到"额度超限"却不知道超了多少、该填多少。
  48. //
  49. // 因此把"给用户看的"与"给运维看的"拆成两个字段:
  50. // - PublicMsg 由**出错点显式填写**,只放可对外的事实(数字、层级、上限值);
  51. // - Cause 仍旧只进日志(`Error()` 拼进字符串),永不进响应体。
  52. //
  53. // 安全红线:PublicMsg 里**禁止**放 SQL、表名、堆栈、内部路径、其它用户的金额。
  54. PublicMsg string
  55. Cause error
  56. // base 指向"被 WithCause 附挂底层原因的源 sentinel"(如 ErrNotFound)。
  57. // 仅内部用于 Unwrap 的解包链,导出接口/JSON 均不感知。
  58. base *AppError
  59. }
  60. // Error 实现 error 接口。
  61. //
  62. // 入参:无(值接收在方法集上,调用者无需关心)。
  63. // 出参:
  64. // - string:Cause 非 nil 时为 "Msg: Cause.Error()"(便于日志串联);Cause 为 nil 时仅返回 Msg。
  65. //
  66. // 说明:Cause 参与字符串拼接仅服务于日志/调试;前端展示的文案是 Handler 单独取 Msg,不受 Cause 影响。
  67. func (e *AppError) Error() string {
  68. if e.Cause != nil {
  69. return fmt.Sprintf("%s: %s", e.Msg, e.Cause.Error())
  70. }
  71. return e.Msg
  72. }
  73. // Unwrap 暴露底层原因链,支持 errors.Is / errors.As 逐层解包(Go 1.20+ 多错误解包)。
  74. //
  75. // 入参:无(指针接收在方法集上,调用者无需关心)。
  76. // 出参:
  77. // - []error:解包链,按序含 Cause 与 base(源 sentinel);去重且过滤 nil 后返回。
  78. // 空切片表示"无底层可解包"(既无 Cause 也无 base,如 New 创建的自定义错误)。
  79. //
  80. // 关键设计(使 errors.Is 命中 WithCause 的源 sentinel):
  81. // WithCause(ErrNotFound, gormErr) 返回的 *AppError 是 ErrNotFound 的**副本**(Code/Msg 拷贝),
  82. // 与 ErrNotFound 指针不同。若 Unwrap 只回 Cause(gormErr),errors.Is(err, ErrNotFound) 会
  83. // 走 err→gormErr→nil,永远找不到 ErrNotFound。故这里把 base(源 sentinel 指针)一并解出:
  84. //
  85. // err := WithCause(ErrNotFound, fmt.Errorf("sql: no rows"))
  86. // err.Unwrap() // [gormErr, ErrNotFound]
  87. // errors.Is(err, ErrNotFound) // true(命中 base)
  88. func (e *AppError) Unwrap() []error {
  89. // 去重:若 base 与 Cause 恰好是同一 *AppError(调用方把 sentinel 本身当 cause 传入),只保留一份。
  90. var chain []error
  91. if e.Cause != nil {
  92. chain = append(chain, e.Cause)
  93. }
  94. if e.base != nil && e.base != e.Cause {
  95. chain = append(chain, e.base)
  96. }
  97. return chain
  98. }
  99. // New 创建一个新的 AppError(自定义 code 与 msg)。
  100. //
  101. // 入参:
  102. // - code int:分段错误码(遵循 1xxx~5xxx 分段;传 200 视为误用,成功请用 response.OK)。
  103. // - msg string:面向用户的错误文案(应已脱敏)。
  104. //
  105. // 出参:
  106. // - *AppError:新建的业务错误;Cause 为 nil。
  107. //
  108. // 异常场景:
  109. // - code 传 0 或负数:不校验(调用方责任),但建议单测断言 code 落在分段区间内。
  110. //
  111. // 样例:
  112. //
  113. // return &errors.AppError via errors.New(3002, "该手机号已被绑定")
  114. func New(code int, msg string) *AppError {
  115. return &AppError{Code: code, Msg: msg}
  116. }
  117. // WithCause 为已有 sentinel 附加底层原因,返回新的 *AppError。
  118. //
  119. // 入参:
  120. // - base *AppError:目标业务错误(如 ErrNotFound);为 nil 时返回 cause 包装成的 ErrInternal。
  121. // - cause error:底层原因(ORM 原错/第三方错误);为 nil 时直接返回 base 的副本。
  122. //
  123. // 出参:
  124. // - *AppError:Code/Msg 继承自 base,Cause 置为 cause,base 回引用指向入参 base
  125. // (不修改入参 base,避免共享污染;回引用使 errors.Is 能命中源 sentinel,见 Unwrap)。
  126. //
  127. // 异常场景:
  128. // - base 为 nil:返回 ErrInternal 的副本并挂上 cause(base 回引用指向 ErrInternal,防御调用方传错)。
  129. //
  130. // 样例:
  131. //
  132. // err := errors.WithCause(errors.ErrConflict, dbErr)
  133. // stderrors.Is(err, errors.ErrConflict) // true
  134. func WithCause(base *AppError, cause error) *AppError {
  135. if base == nil {
  136. return &AppError{Code: ErrInternal.Code, Msg: ErrInternal.Msg, Cause: cause, base: ErrInternal}
  137. }
  138. return &AppError{Code: base.Code, Msg: base.Msg, Cause: cause, base: base}
  139. }
  140. // WithPublicMsg 在 WithCause 的基础上**额外**给出一条可对外展示的具体文案。
  141. //
  142. // 入参:
  143. // - base *AppError:错误码来源 sentinel(nil → 降级 ErrInternal);
  144. // - cause error:诊断信息(只进日志);
  145. // - publicMsg string:**进响应体**的具体文案,由出错点填写可对外的事实。
  146. //
  147. // 出参:
  148. // - *AppError:Code 同 base;PublicMsg 覆盖对外文案;Cause 保留诊断链。
  149. //
  150. // 使用纪律(三条,缺一就会退化成"把内部细节漏给前端"):
  151. //
  152. // ① 只在"用户需要知道具体值才能改正操作"的场景用 —— 例如"额度不得超过上级:
  153. // 本次 200.00 元 > 上级额度 100.00 元"。用户看得到数字才会改;
  154. // ② 数据异常/内部故障**不要**用它 —— 那些场景用户无法处理,应走 5001 并留日志
  155. // (拿"分账树自环"给校区主任看没有任何意义,还会泄漏内部结构);
  156. // ③ publicMsg 里禁止出现 SQL、表名、堆栈、其它用户的数据。
  157. //
  158. // 样例:
  159. //
  160. // infraerrors.WithPublicMsg(apperr.ErrDivideQuotaExceeded,
  161. // errString("service: 额度超父(本次 200.00 > 父 100.00)"),
  162. // "分成额度不得超过上级:本次 200.00 元,上级额度 100.00 元")
  163. func WithPublicMsg(base *AppError, cause error, publicMsg string) *AppError {
  164. if base == nil {
  165. return &AppError{
  166. Code: ErrInternal.Code, Msg: ErrInternal.Msg,
  167. PublicMsg: publicMsg, Cause: cause, base: ErrInternal,
  168. }
  169. }
  170. return &AppError{
  171. Code: base.Code, Msg: base.Msg,
  172. PublicMsg: publicMsg, Cause: cause, base: base,
  173. }
  174. }
  175. // 分段错误码常量(与 R.Code 对齐;新接口一律引用这些常量,禁止散落 magic number)。
  176. const (
  177. CodeOK = 200
  178. CodeUnauth = 1001 // 未登录
  179. CodeTokenExpired = 1002 // 登录态失效
  180. CodeForbidden = 1003 // 无权限
  181. CodeScopeViolation = 1004 // 作用域越界(跨机构/跨校区)
  182. CodeBadParam = 2001 // 参数缺失
  183. CodeBadFormat = 2002 // 参数格式错误
  184. CodeRuleConflict = 2003 // 业务规则冲突
  185. CodeNotFound = 3001 // 记录不存在
  186. CodeConflict = 3002 // 数据冲突
  187. CodeUnique = 3003 // 唯一约束
  188. CodeAgentDown = 4001 // Agent 不可达
  189. CodePayCallback = 4002 // 支付回调失败
  190. CodeThirdTimeout = 4003 // 第三方超时
  191. CodeThirdNotConfigured = 4004 // 第三方服务未配置(如微信 per-campus appid/secret 缺失)
  192. CodeInternal = 5001 // 内部错误
  193. CodeDBError = 5002 // 数据库异常
  194. CodeThrottled = 5003 // 限流
  195. )
  196. // 预置 sentinel 错误(业务域直接复用,无需各自 new)。
  197. //
  198. // 每个 sentinel 的语义:
  199. // - ErrUnauth:请求未携带有效登录态(缺 token / token 无效)。
  200. // - ErrTokenExpired:登录态已过期(access 超 30min 且 refresh 也失效)。
  201. // - ErrForbidden:已登录但无该操作权限(角色/权限点不匹配)。
  202. // - ErrUnscoped:作用域越界——试图访问非本机构/非本校区的资源(多租户防线)。
  203. // - ErrBadParam:入参缺失或格式非法(validator 校验失败的统一出口)。
  204. // - ErrNotFound:目标记录不存在(Repository 把 gorm.ErrRecordNotFound 转成本 sentinel)。
  205. // - ErrConflict:数据冲突(如唯一键撞车、状态机非法迁移)。
  206. // - ErrAgentDown:依赖的 Python Agent 不可达(fire-and-forget 语义下仅记日志,主链路不阻断时用本码告警)。
  207. // - ErrInternal:未分类内部错误(兜底,Handler 会把任何非 *AppError 降级到本码)。
  208. // - ErrDBError:数据库异常(连接/事务/SQL 层,非"记录不存在")。
  209. var (
  210. ErrUnauth = &AppError{Code: CodeUnauth, Msg: "未登录"}
  211. ErrTokenExpired = &AppError{Code: CodeTokenExpired, Msg: "登录态失效"}
  212. ErrForbidden = &AppError{Code: CodeForbidden, Msg: "无权限"}
  213. ErrUnscoped = &AppError{Code: CodeScopeViolation, Msg: "作用域越界"}
  214. ErrBadParam = &AppError{Code: CodeBadParam, Msg: "参数错误"}
  215. ErrNotFound = &AppError{Code: CodeNotFound, Msg: "记录不存在"}
  216. ErrConflict = &AppError{Code: CodeConflict, Msg: "数据冲突"}
  217. ErrAgentDown = &AppError{Code: CodeAgentDown, Msg: "AI 服务暂不可用"}
  218. ErrInternal = &AppError{Code: CodeInternal, Msg: "系统繁忙"}
  219. ErrDBError = &AppError{Code: CodeDBError, Msg: "数据服务异常"}
  220. )
  221. // ErrBadFormat 入参"格式/取值"非法(2002:金额字符串非法、枚举不在白名单、超业务上限等)。
  222. //
  223. // 与 ErrBadParam(2001) 的分工(设计 §10.1「参数错 2001/2002」):
  224. // - 2001:**缺参**(字段没传/为空/缺失必填项);
  225. // - 2002:**传了但值不合法**(金额 <=0 或格式非法、type 不在白名单、金额超单笔业务上限)。
  226. //
  227. // 资金/退费域按此分工显式区分,前端可据此提示"漏填"还是"填错"。
  228. // 独立成块(不并入上方 var 组)以免改动既有行的 gofmt 对齐——CRLF 检出纪律见设计 §2.6 T-CO-1。
  229. var ErrBadFormat = &AppError{Code: CodeBadFormat, Msg: "参数格式错误"}