展示错误:统一信封,日志与客户端分离
上级:structure
抵达用户的错误都被包进同一种信封结构;错误的根因单独记录在日志里审计,绝不暴露给客户端。
后端信封结构
backend/internal/infra/apierr/ (moved under infra/ with 292ce86d2, 2026-07-26):DisplayError 接口是纯结构性的,实现它不需要依赖 apierr:
type DisplayError interface {
error
HTTPStatus() int
DisplayCode() string
DisplayMessage() string
}
任何包都能满足这个接口而无需导入 apierr(不存在依赖倒置)。两个构造函数:
Display(status, code, message):包一条面向用户的消息。DisplayWrap(status, code, message, cause):把两者都包进去。cause通过Unwrap()流向日志,永不流向客户端;Error()会带上 cause,供结构化日志使用;DisplayMessage()则始终保持友好。
Handler 分类:Classify()
handler 里的 Classify(err, cases) 把复杂度限制住:
- 先用
errors.As判断是否为DisplayError→ 是就直接渲染Envelope{Status, Code, Message}。 - 否则,按声明的
cases(一个Case{Match error, Envelope Envelope}的切片 ——classify.go:19)依次用errors.Is匹配,命中第一个即用。 - 都不中,就回落到 500 的
server_error。
这让 handler 的圈复杂度保持在 ≤2;handler 本身不写分支逻辑。
前端镜像:APIError 与分支处理
app/src/lib/api/api-error.ts 定义了读取该信封的 class APIError:
interface Envelope {
error: {code: string, message: string}
}
class APIError {
status: number
code: string
message: string
}
前端按状态码分支:
- 401 未授权:
use-report-error.ts重定向到登录页(一条用户只能干瞪眼的 toast 没有意义)。 - 409 冲突:不走中央分支,而是在调用处处理 —— mutation hook 把信封里的消息留在表单旁边(
lib/admin/use-providers.ts、use-microsites.ts、use-assets.ts各自对 409 分支),绝不弹 toast。 - 其余情况:走
lib/ui/toast.tsx+use-report-error.ts弹 toast。
按情形分支处理,让错误始终可用:409 不会淹没在一条 toast 里,而是落在它该属于的那个表单字段上。
生产环境实例
routes/public/inference_models.go 是第一个调用方;错误码本身现在住在 internal/infra/providermodels/list.go(admin 侧和公开侧共用)。其错误情形(都是 400——已在代码中核实):
no_model_list——Display(400, …)。endpoint_required——Display(400, …)。provider_unreachable——DisplayWrap(400, …, err):友好消息对外,包裹后的 cause 流向日志。
类视图
这个结构化接口就是全部的巧妙之处:任何包无需导入 apierr 就能满足 DisplayError——箭头永远指向接口,从不指向具体的包。
原则
一种线上信封形状,两种受众:
- 运维方在日志里拿到根因(结构化、可调试)。
- 用户拿到友好、可操作的消息(不泄漏、不带术语)。
这个分离是在信封边界上强制执行的,而不是留给每个 handler 各自决定。