371 lines
17 KiB
Markdown
371 lines
17 KiB
Markdown
# 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` 是唯一能让人在不查码表时看懂的东西。
|
|||
|
|
|
|||
|
|
### 分段方案(建议,待后端确认)
|
|||
|
|
|
|||
|
|
`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 != 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 阻断操作,而大部分错误用户能做的只有"知道了"。
|
|||
|
|
|
|||
|
|
### 统一的错误文案映射
|
|||
|
|
|
|||
|
|
```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 §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` 与全局异常处理](./backend/06-api-design.md)
|
|||
|
|
- [backend/08-observability.md:traceId 全链路](./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)
|