Skip to content

对接后端:响应契约与错误码

生成出来的每个 API 函数,最后都得把后端的响应收成一样东西:要么一个能用的值,要么一个能展示的错误。这件事全站只有一个地方在做。它得同时应付两种响应形状:业务信封带着一个数字错误码,框架的 ProblemDetails 却连码都没有。

unwrapApiError

内核包的 api/index.ts、应用的 src/api/<域>.ts,里面的 API 函数都是手写的。gen:api 只生成 schema.d.ts 的类型,函数得自己写。它们绝大多数最后落在 .then(r => unwrap<T>(r)) 上。这里的 r 就是 client.GET/POST(...) 直接 resolve 出来的原始结果,形状固定是 { data, error, response },这是 openapi-fetch 自己的约定,不是 SmartAdmin 发明的。分页端点落在 toPage,但它内部仍旧调 unwrap。只有文件下载不一样,它走 parseAs: 'blob',响应根本不是信封,自己判 response.ok 就行。unwrap 就是容忍后端两种响应形状的那个地方。它把两种情况都收拢成一个 T,或者一个抛出的 ApiError

ts
export function unwrap<T>(res: { data?: unknown; error?: unknown; response: Response }): T {
  const { data, error, response } = res
  if (error !== undefined && error !== null) {
    const env = error as Envelope
    if (typeof env.code === 'number') {
      throw new ApiError(env.code, env.msgKey, env.args, env.message)
    }
    const pd = error as { title?: string; detail?: string }
    throw new ApiError(response.status, undefined, undefined, pd.title ?? pd.detail ?? response.statusText)
  }
  const env = (data ?? {}) as Envelope
  if (typeof env.code === 'number' && env.code !== 0) {
    throw new ApiError(env.code, env.msgKey, env.args, env.message)
  }
  return env.data as T
}

两种响应形状,分别对应后端的两层:

  • 业务信封Result<T>,形状是 { code, msgKey, args, data }。2xx 正常响应走它,缺权限(403)、令牌无效(401)这类业务级失败也走它。判据很简单:code !== 0 就抛出一个 ApiError,带上后端的数字码和 msgKey
  • ProblemDetails:ASP.NET 框架自己的错误形状,长这样 { title, detail, ... },没有 code 字段。它对应的是业务代码根本没跑起来、就被框架拦下的情况,比如模型校验失败(400)、未处理异常(500)。这类会被包装成一个 ApiError,由 HTTP 状态码加 title/detail 拼出来。

ApiError 携带的信息够展示也够程序化判断:

ts
export class ApiError extends Error {
  code: number
  msgKey?: string
  args?: Record<string, unknown>
}

视图层这样接:

ts
try {
  await userApi.remove(id)
} catch (err) {
  message.error(translateError(err))
}

至于 translateError 怎么把 err 变成那句中文或英文提示,是下面「错误码怎么变成文案」一节的事。

分页帮手:pageParams / toPage

每个列表接口都要重复同样两处转换,干脆抽出来:

ts
export const pageParams = (p: { page: number; pageSize: number }) => ({ Current: p.page, Size: p.pageSize })

export function toPage<T>(res: Parameters<typeof unwrap>[0]): { items: T[]; total: number } {
  const p = unwrap<PagedList<T>>(res)
  return { items: p.items, total: p.total }
}
  • 请求方向:前端的 { page, pageSize } 变成后端 record 的 PascalCase { Current, Size } 查询参。ASP.NET 绑定对名字大小写其实不敏感,但本仓库的约定是用 Pascal。...pageParams(p) 展开进每个接口自己强类型的查询对象里,和各自的业务过滤条件并排放。
  • 响应方向:后端的 PagedList<T>{ current, size, total, items })变成 { items, total }。这正是 SmartTable fetcher 契约要的形状。

错误码怎么变成文案

unwrap 抛出的 ApiError 带着一个数字 code 和一个 msgKey,把这两样落成一句中文或英文提示,是前后端约定里最不直观的一环。

约定的根子是一句话:后端从不下发本地化文案。每个业务错误都是一个数字 ErrorCode,定义在 backend/src/SmartAdmin.Core/ErrorCode.cs。每个枚举成员标一个 [MsgKey("...")] 点分路径:

csharp
/// <summary>文件超过大小上限;args 可携带 maxSizeMb</summary>
[MsgKey("error.file.tooLarge")]
FileTooLarge = 44002,

为什么这么设计?反过来想:要是后端直接吐一句翻译好的文案,它就得先知道这个用户有没有切成英文,这样一来错误码和语言状态就焊死在一起,谁都别想单独换。现在的做法是后端只给码,前端拿码查文案,同一个错误在中英文两套词典里各写一句,翻译因此只发生这一次、只发生在前端。

ErrorCodeExtensions.GetMsgKey() 首次访问时反射一次,把这个键取出来,结果缓存进 FrozenDictionary没标 [MsgKey] 的码会回退成 error.code.{数值}。比如一个没映射的 44099,产出的就是 error.code.44099。这个兜底有什么用?漏标注时不会抛异常,只会退化成一个还能定位问题的字符串。

前端的 error.* i18n 命名空间和这些点分路径逐一对应:

ts
// 内核包 locales/zh-CN.ts
error: {
  _fallback: '操作失败,请稍后重试',
  file: {
    tooLarge: '文件超出大小限制',
    // ...
  },
}

utils/error.tstranslateError 是两边真正碰头的地方,应用从 smart-admin-web 导入它:

ts
export function translateError(err: unknown): string {
  const abortKey = abortMsgKey(err)
  if (abortKey) return t(abortKey)
  if (typeof err === 'number') {
    const key = CODE_MSG_KEY[err]
    if (key && i18n.global.te(key)) return t(key)
    return t('error._fallback')
  }
  if (err instanceof ApiError) {
    if (err.msgKey && i18n.global.te(err.msgKey)) return t(err.msgKey, err.args ?? {})
    if (!err.msgKey && CODE_MSG_KEY[err.code] && i18n.global.te(CODE_MSG_KEY[err.code])) {
      return t(CODE_MSG_KEY[err.code], err.args ?? {})
    }
    if (err.message) return err.message
  }
  if (err instanceof Error && err.message) return err.message
  return t('error._fallback')
}

超时和主动取消先被 abortMsgKey 认出来,换成专门的文案。其余按 msgKey 取字,args 一并传进去填占位符。没有 msgKey 时才退到数字码白名单 CODE_MSG_KEY,那份白名单只收内核自己的码。i18n.global.te(key) 只检查当前语言里存不存在这个键。它是纯本地判断,不发网络请求。所以一个 FileTooLarge(44002)的完整链路是这样:

  1. 后端抛 AdminException(ErrorCode.FileTooLarge)
  2. 响应信封携带 code: 44002msgKey: "error.file.tooLarge",外加一段非本地化的 message(仅作调试兜底)。
  3. translateError 判断 i18n.global.te('error.file.tooLarge')true → 渲染 t('error.file.tooLarge')"文件超出大小限制" / "File too large"

错误键本身怎么加、命名空间怎么组织,都和普通文案键没区别。那是 国际化 的事。

加错误码是一次改动的两半

假设内核新加一个错误码 [MsgKey("error.file.duplicateHash")] FileDuplicateHash = 44007,但没人在内核的 zh-CN.ts / en-US.ts 补上 error.file.duplicateHash。会发生什么?i18n.global.te(...) 返回 falsetranslateError 退到 err.message。而 err.message 默认就等于 msgKey 本身,因为 Result.Message = message ?? code.GetMsgKey()。于是用户界面上直接弹出的,是 error.file.duplicateHash 这串原始键。加一个后端 ErrorCode,和加对应的前端 error.xxx.yyy 键,是同一次改动的两半。这还不只是运行期难看。ErrorCodeLocaleConsistencyTests 会校验每个 [MsgKey] 的叶子段在两份语言包里都存在。漏配会让后端测试直接变红,可人往往跑去前端找原因。应用自己的错误码不在这条测试的范围里,文案写进 src/locales/ext/<locale>/error.ts,两种语言都要有。

重新生成契约

schema.d.ts 不是手写的。它是从一个正在运行的后端实例,抓 /openapi/v1.json 生成出来的类型。改了后端接口或 DTO,在对应的 web/ 下重新生成一遍:

bash
npm run gen:api

关键就在「正在运行」四个字。gen:api 走的是 openapi-typescript 在线抓取,不是离线扫代码。所以跑之前后端得先起着,确认 http://localhost:5100/openapi/v1.json 能访问,gen:api 才有契约可抓。起后端可以用 dotnet run --project backend/samples/MinimalHost,或者 dev-start.bat 一键起前后端。后端没启动,这个端点就访问不到,命令也拿不到数据。

WARNING

schema.d.ts 是生成产物,不要手改。改了它,下次一跑 gen:api 就被覆盖。要调整类型,去改后端的接口或 DTO,再重新生成。

信封本身在后端怎么拼出来,归 请求管线 那页:认证、[RolePermission]、数据范围过滤,还有这页 unwrap 消费的 Result<T> 到底在哪一步套上。

基于 Apache License 2.0 开源