← 返回笔记首页

跨端统一错误处理方案分析

Web / Employee App / Guest App 三端错误处理现状与统一方案 · 2026-02-06

1. 我们在解决什么问题

后端已经建立了一套统一错误码系统(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 个,已过时
核心问题
两个 Flutter App 不理解后端的结构化错误响应。它们只是粗暴地按 HTTP 状态码(400/401/404...)分类,丢掉了 code、type、traceId 这些有价值的信息。这导致:

2. 背景知识:错误处理的两个层次

要理解三个方案的区别,先要分清错误处理的两个独立层次:

层次 1:结构化解析(Parsing)

把后端返回的 JSON 解析成有类型的对象,提取 codetypetraceId不管选哪个方案,这一步都要做。

// 后端返回的 JSON → 结构化的 Dart 对象
ApiErrorResponse {
  code: "E5001"        // 可以用来判断具体是什么错
  type: "GUEST_NOT_FOUND"  // 语义化名称
  message: "客户不存在"     // 后端已翻译的消息
  traceId: "abc123"        // 可以用来查日志
}

层次 2:消息本地化(Localization)

决定最终展示给用户的文字从哪里来。这才是三个方案的分歧点:

用户看到的错误消息从哪来? 方案 A:客户端查表(独立 Dart Map) error-codes.json → 脚本生成 → error_messages.dart (Map) 运行时: code "E5001" → map["E5001"]["zh"] → "客户不存在" 方案 B:客户端查表(Flutter ARB) error-codes.json → 脚本生成 → app_en.arb / app_zh.arb 运行时: code "E5001" → AppLocalizations.errorE5001 → "客户不存在" 方案 C:直接用后端消息 后端根据 Accept-Language 返回已翻译的 message 运行时: response.error.message → "客户不存在"(后端已翻译好的)

3. 三个方案详解

方案 A:独立 Dart Map(代码生成)

思路:从 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;
优点缺点
  • 实现简单,一个 Map 搞定
  • 支持动态 key 查找(map[code]
  • 与后端定义自动同步
  • 错误消息和 app 其他翻译(ARB)分成两套系统
  • 翻译工具(如 Crowdin)无法统一管理
  • 新增错误码需要重跑脚本

方案 B:集成 Flutter ARB 系统

思路:把 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 条函数引用
};
优点缺点
  • 与 app 其他翻译统一管理
  • 翻译工具可以一并处理
  • Flutter 官方推荐方式
  • ARB 文件膨胀(250+ 条)
  • Flutter l10n 不支持动态 key,需要额外的 lookup map
  • 构建步骤多(跑脚本 → gen-l10n → build_runner)
  • 总体复杂度高
为什么 Flutter l10n 不支持动态 key?

Flutter 的 gen-l10n 工具会把 ARB 文件编译成具体的 Dart getter 方法(get errorE5001),而不是 Map。这是设计决策——编译时类型安全,防止拼写错误。但代价是你不能传一个运行时才知道的字符串去查翻译。

对比 Web 端的 next-intlt('errors.guest.notFound') 天然支持动态 key,所以 Web 端没有这个问题。

方案 C:直接用后端消息

思路:后端已经根据请求头 Accept-Language 返回对应语言的 message 字段,客户端直接显示。

// 后端返回(Accept-Language: zh)
{ "error": { "code": "E5001", "message": "客户不存在" } }

// 后端返回(Accept-Language: en)
{ "error": { "code": "E5001", "message": "Guest not found" } }

// 客户端直接用
showError(response.error.message);  // 不需要客户端翻译
优点缺点
  • 零额外工作量(翻译维护在后端)
  • 保证前后端消息一致
  • 新增错误码不需要更新客户端
  • 客户端无法自主控制措辞(想改措辞必须改后端)
  • 离线/网络错误没有后端消息可用,需要 fallback
  • 用户切语言后,之前缓存的错误消息语言可能不对

4. 推荐方案:C + A 混合

务实选择

为什么不全用方案 A 或 B?

因为后端已经做了翻译工作error-codes.json 里每个码都有 messages.enmessages.zh,后端的 errorHandler.js 会根据请求语言返回对应消息。在客户端再维护一份完全相同的翻译是重复劳动。只有在后端消息不可用(网络断开)或需要客户端覆盖措辞时,才需要本地 fallback。

为什么不全用方案 C?

因为有些错误不来自后端:用户断网了、请求超时了、证书验证失败——这些情况根本没有后端响应,需要客户端自己生成本地化消息。

5. 不管选哪个方案都要做的事

三个方案的区别只在"消息文字从哪来"。但以下工作是方案无关的核心改造

5.1 结构化错误解析器

让 Flutter app 能正确解析后端的 V2 错误响应格式,提取 codetypetraceIdmessagedetails

5.2 智能错误路由

根据错误码做不同处理,而不是只看 HTTP 状态码:

// 现在(粗暴)
HTTP 401 → 跳转登录(但 401 可能是 token过期/token无效/IP黑名单 等不同原因)

// 改进后(精确)
E2003 AUTH_TOKEN_EXPIRED → 静默刷新 token,重试请求
E2004 AUTH_TOKEN_INVALID → 清除 token,跳转登录
E2006 AUTH_SESSION_EXPIRED → 显示"会话已过期",跳转登录
E3002 PERMISSION_INSUFFICIENT → 显示"权限不足",不跳转

5.3 TraceId 日志记录

把 traceId 记录下来,用户报 bug 时可以快速定位后端日志:

// 客户端日志
[ERROR] API E5102 APPOINTMENT_TIME_CONFLICT | traceId: 1707148800000-a3f2b1

// 后端可以用 traceId 查到完整调用链
grep "1707148800000-a3f2b1" backend/logs/application-*.log

5.4 统一错误码常量

shared/errors/error-codes.json 自动生成 Dart 常量,替换 Guest App 手写的过时常量。

6. 补充知识:Accept-Language 机制

方案 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);
  },
));

7. 补充知识:错误码设计范式

为什么不直接用 HTTP 状态码?

HTTP 状态码只有几十个,粒度太粗。比如 400 Bad Request 可能是:

都是 400,但客户端需要不同的处理方式和不同的提示语。错误码提供了这个精细度。

E#### 格式的设计逻辑

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",是因为系统处于迁移期:

8. 我们项目的文件分布

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 常量

9. 总结对比

方案 A: Dart Map 方案 B: ARB 集成 方案 C: 后端消息
消息来源 客户端 Map 查表 客户端 AppLocalizations 后端 response.error.message
动态 key ✅ map[code] ❌ 需要额外 lookup ✅ 不需要查表
离线可用 ❌ 需要 fallback
翻译统一管理 ❌ 分两套 ✅ 一套 ARB ✅ 后端统一
新增错误码 跑脚本重新生成 跑脚本 + gen-l10n 无需更新客户端
复杂度 最低
推荐场景 作为 fallback 兜底 需要客户端完全控制措辞 主力方案
推荐:C + A 混合

业务错误用方案 C(后端消息),客户端本地错误用现有 ARB,再用方案 A 生成 fallback Map 兜底。这样工作量最小、维护成本最低,同时保证了所有场景都有翻译覆盖。