Web / Employee App / Guest App 三端错误处理现状与统一方案 · 2026-02-06
后端已经建立了一套统一错误码系统(125 个 E#### 格式的错误码,存储在 shared/errors/error-codes.json),API 返回结构化的错误响应:
{
"success": false,
"error": {
"code": "E5001", // 错误码(新格式)
"type": "GUEST_NOT_FOUND", // 语义类型(旧格式,向后兼容)
"message": "客户不存在", // 已翻译的消息(根据 Accept-Language)
"traceId": "1707148800000-a3f2b1", // 追踪 ID(用于日志关联)
"details": { "guestId": "123" } // 额外上下文(可选)
},
"timestamp": "2026-02-06T10:00:00.000Z"
}
但三个客户端对这个结构的利用程度完全不同:
| 能力 | Web Frontend 已集成 | Employee App 未集成 | Guest App 未集成 |
|---|---|---|---|
解析 error.code (E####) |
✅ 完整解析 | ⚠️ 提取了但没用 | ❌ 不看这个字段 |
解析 error.type |
✅ 双格式支持 | ❌ | ❌ |
解析 error.traceId |
✅ 记录日志 | ❌ | ❌ |
| 智能错误路由(token过期→跳登录) | ✅ 按错误码判断 | ⚠️ 仅靠 HTTP 401 | ⚠️ 仅靠 HTTP 401 |
| 错误消息本地化 | ✅ i18n 映射 | ❌ 显示英文服务器消息 | ❌ 硬编码英文 fallback |
| 错误码常量与后端同步 | ✅ 代码生成 | ❌ 无 | ❌ 手写了 9 个,已过时 |
要理解三个方案的区别,先要分清错误处理的两个独立层次:
把后端返回的 JSON 解析成有类型的对象,提取 code、type、traceId。不管选哪个方案,这一步都要做。
// 后端返回的 JSON → 结构化的 Dart 对象
ApiErrorResponse {
code: "E5001" // 可以用来判断具体是什么错
type: "GUEST_NOT_FOUND" // 语义化名称
message: "客户不存在" // 后端已翻译的消息
traceId: "abc123" // 可以用来查日志
}
决定最终展示给用户的文字从哪里来。这才是三个方案的分歧点:
思路:从 shared/errors/error-codes.json 自动生成一个 Dart 文件,包含所有错误码到消息的映射。
// 自动生成的文件 lib/core/errors/error_messages.g.dart
// ⚠️ AUTO-GENERATED — DO NOT EDIT
const Map<String, Map<String, String>> errorMessages = {
'E5001': {'en': 'Guest not found', 'zh': '客户不存在'},
'E5102': {'en': 'Time slot conflict', 'zh': '时间段冲突'},
// ... 125 条
};
// 使用
String msg = errorMessages[code]?[locale] ?? response.error.message;
| 优点 | 缺点 |
|---|---|
|
|
思路:把 125 个错误码的翻译注入 ARB 文件,通过 Flutter 官方 gen-l10n 生成 AppLocalizations 的 getter。
// app_en.arb(自动注入)
{
"errorE5001": "Guest not found",
"errorE5102": "Time slot conflict",
// ... 125 条(加上已有的 app 翻译)
}
// Flutter 生成的 AppLocalizations
class AppLocalizations {
String get errorE5001 => 'Guest not found';
String get errorE5102 => 'Time slot conflict';
// ...
}
// ⚠️ 问题:不能动态查找
AppLocalizations.of(context).errorE5001 // ✅ 编译时已知
AppLocalizations.of(context)['errorE5001'] // ❌ 不支持!
// 必须额外生成一个 lookup map
final errorLookup = {
'E5001': (AppLocalizations l) => l.errorE5001,
'E5102': (AppLocalizations l) => l.errorE5102,
// ... 125 条函数引用
};
| 优点 | 缺点 |
|---|---|
|
|
Flutter 的 gen-l10n 工具会把 ARB 文件编译成具体的 Dart getter 方法(get errorE5001),而不是 Map。这是设计决策——编译时类型安全,防止拼写错误。但代价是你不能传一个运行时才知道的字符串去查翻译。
对比 Web 端的 next-intl:t('errors.guest.notFound') 天然支持动态 key,所以 Web 端没有这个问题。
思路:后端已经根据请求头 Accept-Language 返回对应语言的 message 字段,客户端直接显示。
// 后端返回(Accept-Language: zh)
{ "error": { "code": "E5001", "message": "客户不存在" } }
// 后端返回(Accept-Language: en)
{ "error": { "code": "E5001", "message": "Guest not found" } }
// 客户端直接用
showError(response.error.message); // 不需要客户端翻译
| 优点 | 缺点 |
|---|---|
|
|
message为什么不全用方案 A 或 B?
因为后端已经做了翻译工作。error-codes.json 里每个码都有 messages.en 和 messages.zh,后端的 errorHandler.js 会根据请求语言返回对应消息。在客户端再维护一份完全相同的翻译是重复劳动。只有在后端消息不可用(网络断开)或需要客户端覆盖措辞时,才需要本地 fallback。
为什么不全用方案 C?
因为有些错误不来自后端:用户断网了、请求超时了、证书验证失败——这些情况根本没有后端响应,需要客户端自己生成本地化消息。
三个方案的区别只在"消息文字从哪来"。但以下工作是方案无关的核心改造:
让 Flutter app 能正确解析后端的 V2 错误响应格式,提取 code、type、traceId、message、details。
根据错误码做不同处理,而不是只看 HTTP 状态码:
// 现在(粗暴)
HTTP 401 → 跳转登录(但 401 可能是 token过期/token无效/IP黑名单 等不同原因)
// 改进后(精确)
E2003 AUTH_TOKEN_EXPIRED → 静默刷新 token,重试请求
E2004 AUTH_TOKEN_INVALID → 清除 token,跳转登录
E2006 AUTH_SESSION_EXPIRED → 显示"会话已过期",跳转登录
E3002 PERMISSION_INSUFFICIENT → 显示"权限不足",不跳转
把 traceId 记录下来,用户报 bug 时可以快速定位后端日志:
// 客户端日志
[ERROR] API E5102 APPOINTMENT_TIME_CONFLICT | traceId: 1707148800000-a3f2b1
// 后端可以用 traceId 查到完整调用链
grep "1707148800000-a3f2b1" backend/logs/application-*.log
从 shared/errors/error-codes.json 自动生成 Dart 常量,替换 Guest App 手写的过时常量。
方案 C 的关键依赖是后端能根据客户端语言返回翻译消息。这是通过 HTTP 标准头 Accept-Language 实现的:
// 客户端请求
GET /api/appointments/123
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
// 后端 errorHandler.js 读取这个头,决定用哪个语言的消息
const locale = req.headers['accept-language']?.startsWith('zh') ? 'zh' : 'en';
const message = errorDefinition.messages[locale];
Flutter 的 Dio 客户端可以在拦截器中自动设置这个头:
// Dio 请求拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
final locale = Platform.localeName; // 如 "zh_CN"
options.headers['Accept-Language'] = locale.startsWith('zh') ? 'zh' : 'en';
handler.next(options);
},
));
HTTP 状态码只有几十个,粒度太粗。比如 400 Bad Request 可能是:
E1001 验证错误(字段格式不对)E1004 请求体为空E1005 缺少必填字段E5102 预约时间冲突(业务规则)都是 400,但客户端需要不同的处理方式和不同的提示语。错误码提供了这个精细度。
E + 4位数字
第1位 = 大类:
1xxx → 通用错误(验证、格式、限流)
2xxx → 认证错误(登录、Token、会话)
3xxx → 权限错误(RBAC)
5xxx → 业务错误
第2位 = 业务模块(仅 5xxx):
50xx → Guest(客户)
51xx → Appointment(预约)
52xx → Employee(员工)
53xx → Service(服务)
...
后2位 = 模块内序号
后端同时返回 code: "E5001" 和 type: "GUEST_NOT_FOUND",是因为系统处于迁移期:
GUEST_NOT_FOUND(可读性好,但不标准化)E5001(标准化,便于工具处理)shared/errors/error-mapping.json 自动双向转换shared/errors/
├── error-codes.json # 125 个错误码定义(Single Source of Truth)
├── error-mapping.json # 新旧格式映射表
└── schema.json # JSON Schema 校验
scripts/
└── generate-error-codes.js # 代码生成脚本(目前只生成 JS/TS)
backend/
├── middleware/errorHandler.js # 全局错误处理中间件
├── constants/errors.js # 旧格式常量(向后兼容)
├── constants/generated-error-codes.js # 自动生成的新格式常量
└── utils/error-loader.js # 错误码加载器
frontend/web_app/src/
├── lib/error-codes.ts # 错误码映射 + 工具函数(702行)
├── hooks/useApiError.ts # React Hook 错误处理器(417行)
└── types/error-codes.d.ts # 自动生成的 TypeScript 类型
employee_mobile_app/lib/core/api/
├── api_client.dart # Dio 客户端(提取了 code 但没用)
└── api_exceptions.dart # 异常类(有 code 字段但没映射逻辑)
guest_mobile_app/.../lib/core/
├── api/api_client.dart # Dio 客户端
└── utils/error_handler.dart # 硬编码英文消息 + 过时的 ApiErrorCodes 常量
| 方案 A: Dart Map | 方案 B: ARB 集成 | 方案 C: 后端消息 | |
|---|---|---|---|
| 消息来源 | 客户端 Map 查表 | 客户端 AppLocalizations | 后端 response.error.message |
| 动态 key | ✅ map[code] | ❌ 需要额外 lookup | ✅ 不需要查表 |
| 离线可用 | ✅ | ✅ | ❌ 需要 fallback |
| 翻译统一管理 | ❌ 分两套 | ✅ 一套 ARB | ✅ 后端统一 |
| 新增错误码 | 跑脚本重新生成 | 跑脚本 + gen-l10n | 无需更新客户端 |
| 复杂度 | 低 | 高 | 最低 |
| 推荐场景 | 作为 fallback 兜底 | 需要客户端完全控制措辞 | 主力方案 |
业务错误用方案 C(后端消息),客户端本地错误用现有 ARB,再用方案 A 生成 fallback Map 兜底。这样工作量最小、维护成本最低,同时保证了所有场景都有翻译覆盖。