Files
conti-docs/flutter-app/12-error-and-api-contract.md

384 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 12. 错误处理与 API 契约
## 为什么单独一篇
[05-networking.md](./05-networking.md) 定义了"网络层怎么抛异常",但没定义"UI 层怎么显示、什么时候降级、用户看到什么文案"。这两件事必须一起定,否则会出现每个 feature 各写一套错误提示:有的弹 Toast、有的弹 Dialog、有的整页红字、有的干脆什么都不显示。
这一篇负责三件事:**客户端侧的 `ApiResult` 契约**、**`AppException` 体系全貌**、**错误到 UI 的映射规则(含降级)**。
## 一、`ApiResult` 客户端契约
后端所有接口统一返回(见 [backend/06-api-design.md](../backend/06-api-design.md)):
```json
{ "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` 不看码表完全不知道是什么。所以配套要求:
1. **必须有一份双方共享、和代码一起维护的码表**,不能只存在于某个人的 Excel 里。
2. **客户端不允许出现字面量数字**。所有用到的码定义成命名常量,`if (e.code == ApiCode.forbidden)` 而不是 `if (e.code == 10403)`
3. **日志里 code 和 message 一起打**,因为 `message` 是唯一能让人在不查码表时看懂的东西。
### 分段方案
**5 位数字,前 2 位是域段**(与 `backend/06-api-design.md` 一致):
| 段 | 域 | 例 |
|---|---|---|
| `0` | 成功 | `0` |
| `10xxx` | 平台通用 | `10001` 参数错误、`10401` 未登录、`10403` 无权限、`10500` 系统错误 |
| `11xxx` | 认证与门店 | `11001` 门店不可访问、`11002` 无门店权限 |
| `20xxx` | 采购 | |
| `21xxx` | 库存 | |
| `3xxxx` | 外部系统集成 | `30xxx` F6、`31xxx` Mini 域、`32xxx` 阿里云 OCR;后端做过转换,不透传供应商原始码 |
分段的价值是**看到码的前两位就知道该找谁**。全局连续编号(1、2、3…)在多域并行开发时必然撞号。
### `data` 为 `null` 的语义
`code == 0``data == null` 是合法的(后端 `ApiResult.ok(Unit)`)。约定:
```dart
Future<void> data 可以为 null,忽略
Future<T> data null 时抛 ServerException('响应缺少 data'),不返回 null
Future<T?> 显式声明可空时才允许 null
```
不加这层校验的话,后端某个字段漏返回会变成 UI 层莫名其妙的 `Null check operator used on a null value`,排查时完全看不出是接口问题。
### 基础码先定死,业务码开发时增补
**分段方案和下面这组基础码现在就定死,各 domain 段内的业务码在开发对应模块时随接口一起定。** 不等一份"完整码表"齐了再开工——那份表在需求还在动的时候不可能齐,等它等于卡住所有人。
配套的两条策略让码表不齐也能正常工作:
- **默认直接展示后端的 `message`**。后端的 `GlobalExceptionHandler` 已经保证了 `message` 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。**新增业务码不需要客户端改代码**,走的就是这条默认路径。
- **客户端只对"需要特殊 UX 而不只是提示文案"的 code 做分支**,这组要尽可能小。下面这份就是当前的全集:
```dart
// packages/core_network/lib/src/error/api_code.dart
abstract final class ApiCode {
static const ok = 0;
// 10xxx 平台通用
static const invalidParam = 10001; // → 表单内联报错,不弹 Toast
static const unauthorized = 10401; // → 触发刷新 / 登出
static const forbidden = 10403; // → 权限变更,可能要重拉门店上下文
static const notFound = 10404; // → 资源不存在,页面级空态
static const rateLimited = 10429; // → 提示稍后重试,不自动重试
static const internalError = 10500; // → 展示 traceId
// 11xxx 认证与门店
static const storeNotAccessible = 11001; // → 引导重选门店
static const noStorePermission = 11002; // → 退回门店选择页
// 3xxxx 集成
static const ocrUnavailable = 32001; // → 车牌识别不可用,直接切手工输码
static const ocrNoPlateFound = 32002; // → 没识别到车牌,提示重拍
}
```
**增补一个业务码的门槛**:只有当客户端需要"展示文案之外的动作"(跳转、重拉上下文、内联标红、拦截重试)时才加进这个类;只是文案不同的,一律走默认展示。这条不守住,`ApiCode` 会在半年内长成后端码表的副本。
码表本身**和后端代码放在一起维护**(见 `backend/06-api-design.md`),客户端这份常量是它的子集,不是第二份真相。
## 二、`AppException` 体系
```dart
// 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 != 0message 可直接展示
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 阻断操作,而大部分错误用户能做的只有"知道了"。
### 统一的错误文案映射
```dart
// 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](../backend/08-observability.md)),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是**把一次用户投诉精确定位到一条服务端日志**,省掉"大概是下午三点多,某个门店"这种模糊排查。所以:
- **绝大多数错误不展示它**,只有 `ServerException`(5xx / 系统错误)才展示——那正是需要研发介入的场景。
- 无条件写进本地日志和错误上报(见 [13-observability-analytics.md](./13-observability-analytics.md)),这部分不依赖 UI。
不要把 `traceId` 直接印在主文案里(用户看到一串乱码只会更慌)。约定:
```
系统繁忙
请稍后重试
[ 重试 ] 问题反馈 ›
```
「问题反馈」展开后显示 `traceId` 并提供**一键复制**。客服话术是"请点击问题反馈,把那串编号发给我"。
同时 traceId **无条件写进本地日志**(不管展不展示),见 [13-observability-analytics.md](./13-observability-analytics.md)。
### 通用错误 Widget
`core_ui` 提供,所有 feature 复用,不各写一套:
```dart
// 整页
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 REQ-NFR-012:单一外围系统故障时局部降级,首页其余卡片正常展示并标注「暂不可用」——Mini 某一服务失败只影响对应模块,F6 异常不得导致主 APP 全部不可用。
### 首页的降级模型
首页由多块数据组成(门店信息、菜单、待办、预警、公告、促销位),它们来自**不同的后端聚合**,失败是独立的。
**做法:每块数据一个独立 provider,页面不做 `Future.wait`。**
```dart
// ❌ 错的:任何一块失败,整个首页变成错误态
@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 REQ-NFR-012。
**唯一的例外是"没有它整页就没意义"的数据**:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。
### 降级的粒度约定
| 数据 | 失败时 |
|---|---|
| 门店上下文、菜单 | **整页错误态 + 重试**(没有它首页无意义) |
| 待办、预警、公告、促销位 | **该区块显示局部错误态**,其余正常 |
| 首页各 tile 的数字/角标 | **降级为不显示角标**,不显示错误 UI——一个角标加载失败不值得占用用户注意力 |
| H5 页面 | 容器内错误页,不影响 App 其他部分(见 [10-webview-h5.md](./10-webview-h5.md) |
### 有缓存时优先展示缓存
网络失败但本地有缓存(见 [06-local-storage.md](./06-local-storage.md))时,**展示缓存 + 顶部提示条**,比展示一个错误页好得多——门店里网络不稳是常态。
```dart
// 顶部一条细提示条,不遮挡内容
if (state.isFromCache) StaleDataBanner(updatedAt: state.cachedAt, onRefresh: ...)
```
前提是缓存**必须带时间戳并显示**("更新于 10 分钟前")。展示旧数据却不告诉用户是旧的,比展示错误更危险——尤其是库存和价格。
## 五、兜底:没被 catch 的异常
```dart
// 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 已经优雅处理了):
```dart
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](./13-observability-analytics.md)。
### release 模式的错误页
```dart
ErrorWidget.builder = (details) => const AppCrashView(); // 不显示红屏
```
默认的红色错误屏在 release 下也会出现(虽然是灰色的)。换成一个统一的"页面出错了,请返回重试"视图。
## 六、错误处理的反模式
这几条在 review 时直接打回:
```dart
// ❌ 吞掉异常
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`、不用字面量数字。
## 待确认项
- **各 domain 段内的业务码**:分段方案和 `ApiCode` 里的基础码已定死,采购(`20xxx`)、库存(`21xxx`)、外部集成(`3xxxx`)段内的其余码值在开发对应模块时随接口一起定。新增码默认走 `message` 展示,只有需要特殊 UX 的才进 `ApiCode`
- 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(`Idempotency-Key`)支持才能安全重试。当前决策是**不重试、提示用户手动确认结果**。
- 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。
## 参考链接
- [backend/06-api-design.md`ApiResult` 与全局异常处理](../backend/06-api-design.md)
- [backend/08-observability.mdtraceId 全链路](../backend/08-observability.md)
- [Flutter: Handling errors](https://docs.flutter.dev/testing/errors)
- [Riverpod: ProviderObserver](https://pub.dev/documentation/riverpod/latest/riverpod/ProviderObserver-class.html)
- [Dart 3 patterns: switch expressions](https://dart.dev/language/patterns)