Files
conti-retail-app/docs/12-error-and-api-contract.md
T
2026-08-17 15:29:55 +08:00

371 lines
17 KiB
Markdown
Raw 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](../../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)