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

18 KiB
Raw Permalink Blame History

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_networkApiResultInterceptor 里实现,见 05):

情况 客户端行为
HTTP 2xx + code == 0 解包,业务层只拿到 data
HTTP 2xx + code != 0 BusinessException(code, message, traceId)
HTTP 4xx/5xx + body 是 ApiResult 同上,按 codeBusinessException
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…)在多域并行开发时必然撞号。

datanull 的语义

code == 0data == 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,排查时完全看不出是接口问题。

基础码先定死,业务码开发时增补

分段方案和下面这组基础码现在就定死,各 domain 段内的业务码在开发对应模块时随接口一起定。 不等一份"完整码表"齐了再开工——那份表在需求还在动的时候不可能齐,等它等于卡住所有人。

配套的两条策略让码表不齐也能正常工作:

  • 默认直接展示后端的 message。后端的 GlobalExceptionHandler 已经保证了 message 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。新增业务码不需要客户端改代码,走的就是这条默认路径。
  • 客户端只对"需要特殊 UX 而不只是提示文案"的 code 做分支,这组要尽可能小。下面这份就是当前的全集:
// 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 体系

// 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 阻断操作,而大部分错误用户能做的只有"知道了"。

统一的错误文案映射

// 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), // 不展示
    };

要点:

  • BusinessExceptionretryablefalse。业务错误(比如"库存不足""订单已支付")重试没有意义,给一个重试按钮只会让用户反复点。
  • 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 REQ-NFR-012:单一外围系统故障时局部降级,首页其余卡片正常展示并标注「暂不可用」——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 REQ-NFR-012。

唯一的例外是"没有它整页就没意义"的数据:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。

降级的粒度约定

数据 失败时
门店上下文、菜单 整页错误态 + 重试(没有它首页无意义)
待办、预警、公告、促销位 该区块显示局部错误态,其余正常
首页各 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、不用字面量数字。

待确认项

  • 各 domain 段内的业务码:分段方案和 ApiCode 里的基础码已定死,采购(20xxx)、库存(21xxx)、外部集成(3xxxx)段内的其余码值在开发对应模块时随接口一起定。新增码默认走 message 展示,只有需要特殊 UX 的才进 ApiCode
  • 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(Idempotency-Key)支持才能安全重试。当前决策是不重试、提示用户手动确认结果
  • 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。

参考链接