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

371 lines
17 KiB
Markdown
Raw Normal View History

2026-08-17 15:29:55 +08:00
# 12. 错误处理与 API 契约
## 为什么单独一篇
[05-networking.md](./05-networking.md) 定义了"网络层怎么抛异常",但没定义"UI 层怎么显示、什么时候降级、用户看到什么文案"。这两件事必须一起定,否则会出现每个 feature 各写一套错误提示:有的弹 Toast、有的弹 Dialog、有的整页红字、有的干脆什么都不显示。
这一篇负责三件事:**客户端侧的 `ApiResult` 契约**、**`AppException` 体系全貌**、**错误到 UI 的映射规则(含降级)**。
## 一、`ApiResult` 客户端契约
后端所有接口统一返回(见 [backend/06-api-design.md](../../conti-backend/docs/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` 是唯一能让人在不查码表时看懂的东西。
### 分段方案(建议,待后端确认)
`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)`)。约定:
```dart
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 做分支**,这组必须尽可能小:
```dart
// 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` 体系
```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](../../conti-backend/docs/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 §21.1「首页支持部分失败降级」、§21.2「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 §21.1。
**唯一的例外是"没有它整页就没意义"的数据**:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。
### 降级的粒度约定
| 数据 | 失败时 |
|---|---|
| 门店上下文、菜单 | **整页错误态 + 重试**(没有它首页无意义) |
| 待办、预警、公告、促销位 | **该区块显示局部错误态**,其余正常 |
| 首页各 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`、不用字面量数字。
## 待确认项
- **错误码表(最高优先级)**:需要和后端一起把上面的分段方案落成完整码表,特别是 `ApiCode` 里那组需要特殊 UX 的码。这一项不定,客户端只能全部走默认文案。同时 `backend/06-api-design.md` 的「待补充」里也挂着这一条。
- 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(`Idempotency-Key`)支持才能安全重试。当前决策是**不重试、提示用户手动确认结果**。
- 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。
## 参考链接
- [backend/06-api-design.md`ApiResult` 与全局异常处理](../../conti-backend/docs/06-api-design.md)
- [backend/08-observability.mdtraceId 全链路](../../conti-backend/docs/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)