本文以我在开发图片猫项目(对外站点:www.piccat.cn)时整理的一条真实链路为例:图片请求经过模型路由、异常过滤器、前端请求封装和组件提示后,怎样让"错误码给程序判断,错误信息给用户行动,诊断细节留在服务端"。文中会把当前已经实现的部分、仍然分散的部分和建议方案分开说明。

图 1 合成示意图:错误信息从传输层到用户界面的四层边界。
问题场景 为什么同一个失败会出现多种提示
图片处理接口的失败来源比普通 CRUD 接口复杂。上传阶段可能是格式或尺寸不符合要求,处理阶段可能是排队、超时、模型拒绝或网络中断,结果阶段还可能出现响应无法确认。若后端把第三方原始文本直接返回,前端就会被迫通过关键词猜原因;如果前端只看 HTTP 状态,又无法区分"用户应修改图片"和"服务端应稍后重试"。
我在图片处理项目里遇到的实际矛盾是:HTTP 状态码适合表达协议层结果,业务 code 适合表达稳定语义,用户 message 需要可读且可行动,而模型供应商的原始错误既可能包含英文技术词,也可能携带不应该暴露的请求细节。这四类信息必须同时存在,但不能混在一个字符串里。
先看当前代码已经做了什么
后端模型网关先记录错误码再决定最终失败
在 webServer/src/feature-models/feature-model-gateway.service.ts 中,模型尝试记录使用了 errorCode、retryable、errorReason 等字段;最终失败时再把稳定的 code 和面向用户的 message 放进 HttpException。下面是关键片段,保留了项目原有的分支含义,省略了模型调用循环。
项目代码摘录:原文件还包含重试、备选模型、路由轨迹和计费上下文,这里只保留错误语义收口部分。
const message = contentRejectedThenServiceFailed
? '本次处理先遇到内容安全审核拦截,随后又遇到图片处理服务异常。请调整图片或修改要求后重试。'
: code === 'CANCELLED' ? '模型调用已取消'
: code === 'TIMEOUT' ? '图片处理超时,请稍后重试'
: code === 'CONTENT_REJECTED' ? '输入图片或提示词未通过内容安全审核'
: code === 'INVALID_INPUT' ? '图片参数不符合所选模型要求,请调整后重试'
: code === 'MODEL_RESULT_UNKNOWN' ? '模型处理结果暂时无法确认,已停止自动重复调用,请稍后查看任务状态'
: code === 'UNSUPPORTED_IMAGE_SIZE' ? AI_EDIT_ASPECT_RATIO_MESSAGE
: insufficientPixels ? AI_EDIT_INSUFFICIENT_PIXELS_MESSAGE
: '图片处理失败,请稍后重试';
throw new HttpException({ message, code }, status);
这段实现的优点是:模型供应商的失败先被归一成 CONTENT_REJECTED、TIMEOUT、INVALID_INPUT 等内部语义;对于"内容审核后又发生服务异常"这种需要如实解释的组合情况,代码没有把原因压成一个泛化的失败。它还保留了 MODEL_RESULT_UNKNOWN,避免网络中断后盲目重复调用。
全局异常过滤器统一 JSON 外壳和日志入口
webServer/src/common/error-log.filter.ts 使用 @Catch() 捕获异常,读取 HttpException 的响应体,在写入 OperationHistory 时记录错误消息和堆栈;向客户端返回时保留原有 responseBody,并补充 statusCode。这意味着网关生成的 code 可以继续透传,服务端诊断也不会要求前端承担。
项目代码摘录:过滤器还负责错误历史记录和 Python traceback 提取,文中省略。
const responseBody = exception instanceof HttpException
? exception.getResponse()
: { message: exception?.message || 'Internal server error' };
if (!res.headersSent) {
res.status(status).json(
typeof responseBody === 'string'
? { message: responseBody, statusCode: status }
: { ...responseBody, statusCode: status }
);
}
前端保存 code 原文和状态 再决定怎么提示
webClientVue/src/composables/useImageService.ts 对非 2xx 响应先读取文本,再尝试解析 JSON,同时保存 status、data 和 rawMessage。normalizeUserFacingError 负责清洗供应商名称、HTTP 技术细节、RequestId 和堆栈片段;ImageEnhancer.vue 对 CONTENT_SAFETY_REJECTED 还有退款状态的局部展示。这是"通用兜底 + 业务特例"的组合,而不是所有组件都只显示一个字符串。
项目代码摘录:这里只截取响应解析和错误对象构造,后面的额度、登录和处理状态分支已省略。
if (!response.ok) {
const errText = await response.text();
let errData = null;
try {
errData = JSON.parse(errText);
} catch (e) {}
const rawMessage = errData?.message || errText || '服务器响应异常: ' + response.status;
const error: any = new Error(normalizeUserFacingError(rawMessage));
error.status = response.status;
error.data = errData;
error.rawMessage = rawMessage;
throw error;
}
|------------|-------------------------------------------------------------------------------|-------------------------------|
| 层次 | 当前代码中的做法 | 读者需要注意的边界 |
| HTTP 状态 | ErrorLogFilter 透传 statusCode;网关按场景返回 400、502、504 等 | 状态码只说明协议层结果,不能代替业务原因 |
| 业务 code | FeatureModelGatewayService 生成 CONTENT_REJECTED、TIMEOUT、MODEL_RESULT_UNKNOWN 等 | 已有 code 集合仍分布在不同模块,新增接口需登记 |
| 用户 message | 网关生成中文提示,前端 normalizeUserFacingError 做技术细节清洗 | 不能把供应商原文、堆栈和 RequestId 放进用户提示 |
| 诊断信息 | 路由 trace、OperationHistory 和 errorStack 留在服务端 | 客户端只需拿到可行动信息和必要的追踪标识 |
当前仍未完全统一的地方
第一,错误语义已经有集中趋势,但不是一个全局枚举。模型网关有稳定 code,认证、支付、批量工具和部分图片组件又各自维护 code;同一含义可能同时以 HTTP_400、INVALID_INPUT 或一段中文出现。新接口如果只照着已有组件复制,很容易继续增加分支。
第二,前端仍有按 message 正则判断的兼容逻辑。normalizeUserFacingError 能把技术细节清理掉,但它无法保证所有调用方都把 code 传下来;ImageEnhancer.vue 对安全审核和退款的处理也说明,用户提示往往需要"原因 + 下一步 + 额度状态"三个维度,单个 message 字段承载不了所有交互。
第三,重试策略和提示策略还没有完全分离。模型网关内部知道哪些错误可重试,前端却不应仅凭 502 就自动重试;MODEL_RESULT_UNKNOWN 甚至要求先查询任务状态。把 retryable、refundState、taskState 作为结构化字段,能避免客户端从文案反推行为。
建议方案 把错误响应设计成稳定契约
如果要继续扩展图片接口,我会把公共响应契约收敛为下面的形状。HTTP status 负责传输层,code 负责程序分支,message 负责默认人类可读文本,details 负责有限的业务参数,retryable 和 taskState 负责下一步动作。这个结构是建议方案,不代表仓库当前已经全部迁移完成。
教学简化示例:requestId 仅作排查关联,不应包含密钥、用户信息或上游原始请求。
{
"code": "IMAGE_RATIO_UNSUPPORTED",
"message": "当前配置的模型无法按此图片比例完成处理,请调整比例或选择其他档位",
"retryable": false,
"taskState": "failed",
"details": { "operation": "image-ai-edit", "refundState": "not_charged" },
"requestId": "公开给客户端的短标识"
}
|-----------------------------------------|-----------------|-----------------------------------|
| 建议 code | 典型 HTTP 状态 | 前端动作 |
| AUTH_REQUIRED | 401 | 打开登录流程,不显示供应商错误 |
| INVALID_IMAGE / IMAGE_RATIO_UNSUPPORTED | 400 | 提示修改格式、尺寸或比例,不自动重试 |
| CONTENT_SAFETY_REJECTED | 400 或 451 | 解释输入或结果未通过审核,按 refundState 展示额度状态 |
| PROVIDER_TIMEOUT / RATE_LIMIT | 502 / 503 / 504 | 允许用户稍后重试;是否自动重试由服务端策略决定 |
| RESULT_UNKNOWN | 502 | 先查询任务状态,避免重复扣费或重复生成 |
| INTERNAL_ERROR | 500 | 使用通用提示,requestId 供客服或日志检索 |
后端建议 统一抛出可序列化的业务异常
建议代码:不是当前仓库的可直接复制补丁,省略了错误码注册、日志脱敏和兼容旧响应的迁移步骤。
// 建议代码:教学版,接入现有项目时应复用已有 HttpException 和过滤器
type ErrorCode =
| 'AUTH_REQUIRED'
| 'INVALID_IMAGE'
| 'IMAGE_RATIO_UNSUPPORTED'
| 'CONTENT_SAFETY_REJECTED'
| 'PROVIDER_TIMEOUT'
| 'RESULT_UNKNOWN'
| 'INTERNAL_ERROR';
class AppError extends HttpException {
constructor(code: ErrorCode, message: string, status: number,
extra: Record<string, unknown> = {}) {
super({ code, message, ...extra }, status);
}
}
throw new AppError('PROVIDER_TIMEOUT', '图片处理超时,请稍后重试', 504, {
retryable: true,
taskState: 'failed',
});
迁移时不要一次性删除旧字段。可以先让过滤器补齐 code、retryable 和 requestId,再让前端统一解析;旧接口没有 code 时继续走 normalizeUserFacingError 的兜底路径。等监控确认旧格式占比下降,再把新增接口的类型约束收紧。
前端建议 先解析结构 再做展示映射
建议代码:保留 normalizeUserFacingError 作为旧接口和未知 code 的安全兜底。
type ApiError = Error & {
status?: number;
code?: string;
data?: Record<string, unknown>;
};
async function throwApiError(response: Response): Promise<never> {
const payload = await response.json().catch(() => ({}));
const error = new Error(
typeof payload.message === 'string' ? payload.message : '图片处理失败,请稍后重试',
) as ApiError;
error.status = response.status;
error.code = typeof payload.code === 'string' ? payload.code : undefined;
error.data = payload;
throw error;
}
function presentImageError(error: ApiError) {
switch (error.code) {
case 'AUTH_REQUIRED': return '请先登录后再处理图片';
case 'IMAGE_RATIO_UNSUPPORTED': return '请调整图片比例或选择其他档位';
case 'RESULT_UNKNOWN': return '结果暂时无法确认,请先查看任务状态';
default: return normalizeUserFacingError(error.message);
}
}
案例说明 异步处理状态也属于错误设计的一部分
图片处理不一定在一次 HTTP 请求里完成。项目素材中可以看到"AI 无痕改字正在处理"的处理中状态:用户可以等待,也可以关闭页面让任务在后台继续。这个状态不应该被误报成失败,更不能在网络连接暂时中断时立即再次提交。对这类接口,我会把 taskState 作为响应和查询接口的共同字段,至少区分 queued、processing、succeeded、failed、unknown。

图 2 项目素材截图:展示异步处理中的界面状态;这是案例素材,不是错误页,也不是本文重新执行得到的运行截图。
在现有网关中,MODEL_RESULT_UNKNOWN 已经承担了"结果无法确认"的语义;在前端呈现时,它应进入任务查询流程,而不是套用"服务繁忙,请重试"。CONTENT_REJECTED 则属于确定失败,通常可以明确告诉用户调整图片或提示词。把这两个状态分开,是避免重复扣费和重复生成的关键。
边界条件和验证清单
错误码的目标不是把所有异常都命名得更细,而是让每个码都能稳定对应一种处理动作。供应商名称、上游响应体、堆栈和密钥相关字段只写服务端日志;用户界面只拿到必要的 message、retryable、taskState、refundState 和 requestId。
还要区分"已经有源码覆盖"和"发布前建议验收"。仓库中已有针对 CONTENT_REJECTED、MODEL_RESULT_UNKNOWN、路由失败和模型诊断的测试用例;本文没有把这些用例描述成这次重新执行过的测试。发布新契约前,建议补充下面的验收:
- 同一失败在不同图片入口返回同一个 code,HTTP 状态只承担协议层含义。
- 缺少 message、返回非 JSON、网络断开和超时都能安全落到通用提示。
- CONTENT_SAFETY_REJECTED、PROVIDER_TIMEOUT、RESULT_UNKNOWN 的前端动作互不混淆。
- 重试只由 retryable 和服务端策略决定,RESULT_UNKNOWN 不自动重复提交。
- 错误响应、日志和 OperationHistory 中不出现密钥、令牌、用户隐私和完整上游堆栈。
- 旧接口没有 code 时仍能通过 normalizeUserFacingError 显示可读结果,迁移可灰度进行。
参考资料
NestJS 异常过滤器:Exception filters,用于理解如何集中控制异常响应。
MDN HTTP 响应状态码:HTTP response status codes,用于区分 4xx、5xx 与业务错误码的职责。