- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
17 KiB
12. 错误处理与 API 契约
为什么单独一篇
05-networking.md 定义了"网络层怎么抛异常",但没定义"UI 层怎么显示、什么时候降级、用户看到什么文案"。这两件事必须一起定,否则会出现每个 feature 各写一套错误提示:有的弹 Toast、有的弹 Dialog、有的整页红字、有的干脆什么都不显示。
这一篇负责三件事:客户端侧的 ApiResult 契约、AppException 体系全貌、错误到 UI 的映射规则(含降级)。
一、ApiResult 客户端契约
后端所有接口统一返回(见 backend/06-api-design.md):
{ "code": 0, "message": "success", "data": { ... }, "traceId": "a1b2c3..." }
code 是数字,0 表示成功。 客户端契约(在 core_network 的 ApiResultInterceptor 里实现,见 05):
| 情况 | 客户端行为 |
|---|---|
HTTP 2xx + code == 0 |
解包,业务层只拿到 data |
HTTP 2xx + code != 0 |
抛 BusinessException(code, message, traceId) |
HTTP 4xx/5xx + body 是 ApiResult |
同上,按 code 抛 BusinessException |
HTTP 4xx/5xx + body 不是 ApiResult(网关、CDN、Nginx 返回的 HTML) |
抛 ServerException(statusCode, traceId: null) |
| 连接失败 / 超时 | 抛 NetworkException |
第四行是最容易漏的。 请求不一定能到达后端——网关 502、Nginx 413(上传超限)、运营商劫持返回的 HTML 页面,都不会带 ApiResult 结构。直接 jsonDecode 会抛 FormatException,业务层完全接不住。所以解包前必须判断 body 是不是 Map 且含 code 字段。
数字错误码的代价,以及怎么消化它
数字码在日志和监控里聚合方便(可以直接 group by code 出趋势),但它不自解释:日志里一条 code=10403 不看码表完全不知道是什么。所以配套要求:
- 必须有一份双方共享、和代码一起维护的码表,不能只存在于某个人的 Excel 里。
- 客户端不允许出现字面量数字。所有用到的码定义成命名常量,
if (e.code == ApiCode.forbidden)而不是if (e.code == 10403)。 - 日志里 code 和 message 一起打,因为
message是唯一能让人在不查码表时看懂的东西。
分段方案(建议,待后端确认)
backend/06-api-design.md 的待补充项里「按 domain 分段还是全局统一编码」还没定。建议 5 位数字,前 2 位是域段:
| 段 | 域 | 例 |
|---|---|---|
0 |
成功 | 0 |
10xxx |
平台通用 | 10001 参数错误、10401 未登录、10403 无权限、10500 系统错误 |
11xxx |
认证与门店 | 11001 门店不可访问、11002 无门店权限 |
20xxx |
采购 | |
21xxx |
库存 | |
3xxxx |
F6 / Mini 透传类错误 | 后端做过转换,不透传供应商原始码 |
分段的价值是看到码的前两位就知道该找谁。全局连续编号(1、2、3…)在多域并行开发时必然撞号。
data 为 null 的语义
code == 0 但 data == null 是合法的(后端 ApiResult.ok(Unit))。约定:
Future<void> → data 可以为 null,忽略
Future<T> → data 为 null 时抛 ServerException('响应缺少 data'),不返回 null
Future<T?> → 显式声明可空时才允许 null
不加这层校验的话,后端某个字段漏返回会变成 UI 层莫名其妙的 Null check operator used on a null value,排查时完全看不出是接口问题。
完整码表还没定
分段方案(上表)只是骨架,具体的码表还没和后端对齐。在它定下来之前:
- 默认直接展示后端的
message。后端的GlobalExceptionHandler已经保证了message是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。这条策略让客户端在码表缺席时也能正常工作。 - 客户端只对一小组"需要特殊 UX 而不只是提示文案"的 code 做分支,这组必须尽可能小:
// packages/core_network/lib/src/error/api_code.dart
abstract final class ApiCode {
static const ok = 0;
static const invalidParam = 10001; // → 表单内联报错,不弹 Toast
static const unauthorized = 10401; // → 触发刷新 / 登出
static const forbidden = 10403; // → 权限变更,可能要重拉门店上下文
static const internalError = 10500; // → 展示 traceId
static const storeNotAccessible = 11001; // → 引导重选门店
}
这份清单要和后端一起确认,是本文档最重要的待确认项。清单之外的 code 一律走默认展示。
二、AppException 体系
// packages/core_network/lib/src/error/app_exception.dart
sealed class AppException implements Exception {
const AppException(this.message, {this.traceId});
final String message;
final String? traceId;
}
/// 网络不通、超时、DNS 失败——用户重试可能就好了
final class NetworkException extends AppException {
const NetworkException(super.message, {this.kind});
final NetworkErrorKind? kind; // connectTimeout / receiveTimeout / noConnection
}
/// 后端返回了 code != 0,message 可直接展示
final class BusinessException extends AppException {
const BusinessException(this.code, super.message, {super.traceId});
final int code;
}
/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好
final class ServerException extends AppException {
const ServerException(super.message, {this.statusCode, super.traceId});
final int? statusCode;
}
/// token 失效且刷新失败,已触发登出
final class UnauthorizedException extends AppException {}
/// 请求被 CancelToken 取消(页面销毁、用户主动退出)
final class RequestCancelledException extends AppException {}
/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),message 可直接展示
/// 不复用 BusinessException:后者的 code 来自后端错误码表,纯本地的判定没有、也不该编一个 code
final class PreconditionException extends AppException {
const PreconditionException(super.message);
}
/// 本地存储 / 数据库错误
final class StorageException extends AppException {}
/// 原生能力错误(权限拒绝、设备不支持),见 07
final class NativeException extends AppException {
const NativeException(this.code, super.message);
final String code; // PERMISSION_DENIED / UNAVAILABLE / CANCELLED
}
sealed 是有意的:UI 层的错误映射用 switch 穷举,将来新增一种异常类型,所有映射点编译报错,逼着人去处理,而不是悄悄落进 default 分支变成"未知错误"。
RequestCancelledException 必须被 UI 静默处理(见 05)。用户返回上一页时在途请求被取消,弹一个"请求已取消"的 Toast 是纯粹的噪音。
三、错误 → UI 映射
三种展示形态,按"用户当时在干什么"选
| 形态 | 适用 | 例子 |
|---|---|---|
| 整页错误态 | 用户在等这个页面的主数据,没数据页面就是空的 | 订单列表加载失败 |
| 局部错误态 | 页面有多块数据,一块失败不影响其他 | 首页某个 tile 失败 |
| Toast / SnackBar | 用户主动触发了一个动作,失败了要立刻知道 | 提交订单失败、下拉刷新失败 |
| 表单内联 | 参数校验类错误,要指到具体字段 | ApiCode.invalidParam |
不要用 Dialog 报错,除非错误需要用户做决定("登录已过期,是否重新登录")。Dialog 阻断操作,而大部分错误用户能做的只有"知道了"。
统一的错误文案映射
// packages/core_ui/lib/src/error/error_presenter.dart
({String title, String? detail, bool retryable, bool showTraceId}) present(AppException e) =>
switch (e) {
NetworkException(kind: NetworkErrorKind.noConnection) =>
(title: '网络未连接', detail: '请检查网络后重试', retryable: true, showTraceId: false),
NetworkException() =>
(title: '网络不太稳定', detail: '请稍后重试', retryable: true, showTraceId: false),
ServerException() =>
(title: '系统繁忙', detail: '请稍后重试', retryable: true, showTraceId: true),
BusinessException(:final message) =>
(title: message, detail: null, retryable: false, showTraceId: false),
StorageException() =>
(title: '本地数据异常', detail: '请重启 App', retryable: false, showTraceId: false),
NativeException(code: 'PERMISSION_DENIED', :final message) =>
(title: message, detail: '可在系统设置中开启', retryable: false, showTraceId: false),
NativeException(:final message) =>
(title: message, detail: null, retryable: false, showTraceId: false),
UnauthorizedException() || RequestCancelledException() =>
(title: '', detail: null, retryable: false, showTraceId: false), // 不展示
};
要点:
BusinessException的retryable是false。业务错误(比如"库存不足""订单已支付")重试没有意义,给一个重试按钮只会让用户反复点。NetworkException不展示 traceId。请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。ServerException展示 traceId——这正是traceId存在的意义(见 backend/06 附录)。
traceId 怎么展示
traceId 保留,但它是一个低成本、低存在感的字段,不要为它做重的交互。 后端侧它本来就有(TraceIdFilter 写 MDC + 落 ELK,见 backend/08-observability.md),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是把一次用户投诉精确定位到一条服务端日志,省掉"大概是下午三点多,某个门店"这种模糊排查。所以:
- 绝大多数错误不展示它,只有
ServerException(5xx / 系统错误)才展示——那正是需要研发介入的场景。 - 无条件写进本地日志和错误上报(见 13-observability-analytics.md),这部分不依赖 UI。
不要把 traceId 直接印在主文案里(用户看到一串乱码只会更慌)。约定:
系统繁忙
请稍后重试
[ 重试 ] 问题反馈 ›
「问题反馈」展开后显示 traceId 并提供一键复制。客服话术是"请点击问题反馈,把那串编号发给我"。
同时 traceId 无条件写进本地日志(不管展不展示),见 13-observability-analytics.md。
通用错误 Widget
core_ui 提供,所有 feature 复用,不各写一套:
// 整页
AsyncValueView<T>(
value: ref.watch(orderListProvider),
onRetry: () => ref.invalidate(orderListProvider),
data: (orders) => OrderList(orders),
)
// 局部(tile 级降级)
TileErrorView(error: e, onRetry: ...) // 尺寸自适应,不撑破布局
AsyncValueView 内部统一处理:loading 骨架屏、error → present() → 错误态、RequestCancelledException 静默、空数据 → 空态图。每个 feature 自己写 switch (asyncValue) 是最常见的重复劳动,也是三态处理不一致的根源。
四、降级:局部失败不能拖垮整页
PRD §21.1「首页支持部分失败降级」、§21.2「Mini 某一服务失败应仅影响对应模块」「F6 异常不得导致主 APP 全部不可用」。
首页的降级模型
首页由多块数据组成(门店信息、菜单、待办、预警、公告、促销位),它们来自不同的后端聚合,失败是独立的。
做法:每块数据一个独立 provider,页面不做 Future.wait。
// ❌ 错的:任何一块失败,整个首页变成错误态
@riverpod
Future<HomeData> homeData(Ref ref) async {
final (menus, todos, alerts) = await (
ref.watch(menuProvider.future),
ref.watch(todoProvider.future),
ref.watch(alertProvider.future),
).wait;
return HomeData(menus, todos, alerts);
}
// ✅ 对的:各自独立,各自渲染,各自重试
class HomePage extends ConsumerWidget {
Widget build(context, ref) => ListView(children: [
const StoreHeader(),
MenuSection(), // 内部 watch(menuProvider)
TodoSection(), // 内部 watch(todoProvider)
AlertSection(),
]);
}
Future.wait 看起来更"干净",但它把 N 个独立的失败面耦合成了一个——公告服务挂了,用户连待办都看不到。这直接违反 PRD §21.1。
唯一的例外是"没有它整页就没意义"的数据:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。
降级的粒度约定
| 数据 | 失败时 |
|---|---|
| 门店上下文、菜单 | 整页错误态 + 重试(没有它首页无意义) |
| 待办、预警、公告、促销位 | 该区块显示局部错误态,其余正常 |
| 首页各 tile 的数字/角标 | 降级为不显示角标,不显示错误 UI——一个角标加载失败不值得占用用户注意力 |
| H5 页面 | 容器内错误页,不影响 App 其他部分(见 10-webview-h5.md) |
有缓存时优先展示缓存
网络失败但本地有缓存(见 06-local-storage.md)时,展示缓存 + 顶部提示条,比展示一个错误页好得多——门店里网络不稳是常态。
// 顶部一条细提示条,不遮挡内容
if (state.isFromCache) StaleDataBanner(updatedAt: state.cachedAt, onRefresh: ...)
前提是缓存必须带时间戳并显示("更新于 10 分钟前")。展示旧数据却不告诉用户是旧的,比展示错误更危险——尤其是库存和价格。
五、兜底:没被 catch 的异常
// main.dart
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
// widget 构建/布局/绘制期的错误
FlutterError.onError = (details) {
FlutterError.presentError(details); // 保留控制台输出
reporter.recordFlutterError(details);
};
// 平台层/异步的未捕获错误(Flutter 3.3+)
PlatformDispatcher.instance.onError = (error, stack) {
reporter.recordError(error, stack, fatal: true);
return true;
};
runApp(ProviderScope(
retry: (_, __) => null, // 全局关掉自动重试,见 03
observers: [ErrorObserver()],
child: const ContiApp(),
));
}, (error, stack) => reporter.recordError(error, stack, fatal: true));
}
另外在 Riverpod 侧加一个全局观察者,把所有 provider 抛出的错误上报(即使 UI 已经优雅处理了):
class ErrorObserver extends ProviderObserver {
@override
void providerDidFail(context, error, stackTrace) {
if (error is RequestCancelledException) return; // 取消不是错误
reporter.recordError(error, stackTrace, fatal: false, context: {'provider': ...});
}
}
"UI 优雅处理了"和"不需要上报"是两回事。 用户看到一个漂亮的错误页,我们仍然需要知道有多少人看到了它。上报细节见 13-observability-analytics.md。
release 模式的错误页
ErrorWidget.builder = (details) => const AppCrashView(); // 不显示红屏
默认的红色错误屏在 release 下也会出现(虽然是灰色的)。换成一个统一的"页面出错了,请返回重试"视图。
六、错误处理的反模式
这几条在 review 时直接打回:
// ❌ 吞掉异常
try { await repo.submit(); } catch (_) {}
// ❌ 用 catch-all 把所有错误变成同一句话,丢掉了 BusinessException 的 message
try { ... } catch (e) { showToast('操作失败'); }
// ❌ 在 repository / use case 里弹 UI
class OrderRepository {
Future<void> submit() async {
try { ... } catch (e) { showToast(...); } // data 层不能碰 UI,见 02
}
}
// ❌ 用 message 内容做判断
if (e.message.contains('库存')) { ... } // 后端改一个字就失效
// ❌ 写裸数字错误码
if (e.code == 10403) { ... } // 用 ApiCode.forbidden
正确做法:异常一路向上抛到 Notifier,由 AsyncValue 承载,UI 层统一映射。需要分支时用 ApiCode 常量,不用 message、不用字面量数字。
待确认项
- 错误码表(最高优先级):需要和后端一起把上面的分段方案落成完整码表,特别是
ApiCode里那组需要特殊 UX 的码。这一项不定,客户端只能全部走默认文案。同时backend/06-api-design.md的「待补充」里也挂着这一条。 - 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(
Idempotency-Key)支持才能安全重试。当前决策是不重试、提示用户手动确认结果。 - 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。