Template
P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值 P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表 P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图
162 lines
5.5 KiB
Dart
162 lines
5.5 KiB
Dart
import 'package:dio/dio.dart';
|
||
import 'package:sunny_mochi/core/crypto/rsa_helper.dart' show RsaEncryptionException;
|
||
import 'package:sunny_mochi/core/error/failures.dart';
|
||
import 'package:sunny_mochi/core/network/response_code.dart';
|
||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||
|
||
part 'exception_mapper.g.dart';
|
||
|
||
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
|
||
@Riverpod(keepAlive: true)
|
||
ExceptionMapper exceptionMapper(Ref ref) => const ExceptionMapper();
|
||
|
||
/// 把底层异常(DioException / DriftException / 其他)映射为应用层 [Failure]。
|
||
///
|
||
/// - 网络/HTTP → NetworkFailure / ServerFailure / AuthFailure
|
||
/// - 业务 envelope retCode 非成功 → ServerFailure / AuthFailure
|
||
/// - 数据库异常 → CacheFailure
|
||
/// - 兜底 → UnknownFailure
|
||
///
|
||
|
||
class ExceptionMapper {
|
||
const ExceptionMapper();
|
||
|
||
Failure fromDio(DioException e) {
|
||
return switch (e.type) {
|
||
DioExceptionType.connectionTimeout ||
|
||
DioExceptionType.sendTimeout ||
|
||
DioExceptionType.receiveTimeout => NetworkFailure(
|
||
message: '网络超时,请稍后重试',
|
||
kind: NetworkFailureKind.timeout,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
),
|
||
DioExceptionType.connectionError => NetworkFailure(
|
||
message: '网络不可用,请检查连接',
|
||
kind: NetworkFailureKind.noNetwork,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
),
|
||
DioExceptionType.badCertificate => NetworkFailure(
|
||
message: '证书校验失败,可能存在中间人风险',
|
||
kind: NetworkFailureKind.badCertificate,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
),
|
||
DioExceptionType.cancel => NetworkFailure(
|
||
message: '请求已取消',
|
||
kind: NetworkFailureKind.cancelled,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
),
|
||
DioExceptionType.badResponse => _fromHttpStatus(e),
|
||
DioExceptionType.unknown => _fromUnknownDio(e),
|
||
};
|
||
}
|
||
|
||
Failure _fromHttpStatus(DioException e) {
|
||
final status = e.response?.statusCode;
|
||
if (status == 401) {
|
||
// 三态分类(与 TokenRefreshInterceptor 协作 — 反馈文档 §"Token 注销逻辑的编排"):
|
||
// 1. _auth_refresh_failed → refresh 用尽,必须重新登录
|
||
// 2. _auth_not_refreshable → 业务 401(如权限不足),不应跳登录
|
||
// 3. 其他 → 真 token 过期但未触发 refresh(理论不应到这里),按 unauthorized 兜底
|
||
final extra = e.requestOptions.extra;
|
||
if (extra['_auth_refresh_failed'] == true) {
|
||
return AuthFailure(
|
||
message: '登录状态已失效,请重新登录',
|
||
kind: AuthFailureKind.refreshFailed,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
);
|
||
}
|
||
if (extra['_auth_not_refreshable'] == true) {
|
||
return AuthFailure(
|
||
message: _businessAuthMessage(e) ?? '当前账号无访问权限',
|
||
kind: AuthFailureKind.forbidden,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
);
|
||
}
|
||
return AuthFailure(
|
||
message: '登录已过期,请重新登录',
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
);
|
||
}
|
||
if (status == 403) {
|
||
return AuthFailure(
|
||
message: '无访问权限',
|
||
kind: AuthFailureKind.forbidden,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
);
|
||
}
|
||
// message 保留技术细节供 Sentry/Talker;userMessage 由 ServerFailure getter 遮蔽
|
||
return ServerFailure(
|
||
message: e.message ?? 'HTTP $status',
|
||
statusCode: status,
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
);
|
||
}
|
||
|
||
/// 从响应 body 中提取业务侧 message,给业务 401 一个更具体的提示。
|
||
String? _businessAuthMessage(DioException e) {
|
||
final body = e.response?.data;
|
||
if (body is! Map) return null;
|
||
final raw = body['retMsg'] ?? body['msg'] ?? body['message'];
|
||
final msg = raw is String ? raw : raw?.toString();
|
||
return (msg != null && msg.isNotEmpty) ? msg : null;
|
||
}
|
||
|
||
Failure _fromUnknownDio(DioException e) {
|
||
final inner = e.error;
|
||
if (inner is Failure) return inner;
|
||
return UnknownFailure(
|
||
message: e.message ?? '未知错误',
|
||
cause: e,
|
||
stackTrace: e.stackTrace,
|
||
);
|
||
}
|
||
|
||
/// 把业务 envelope 错误码映射为 Failure。
|
||
Failure fromBusinessCode(String? code, String? message) {
|
||
final friendly = ResponseCode.friendlyMessage(code, message ?? '请求失败');
|
||
if (ResponseCode.isUnauthorized(code)) {
|
||
return AuthFailure(message: friendly);
|
||
}
|
||
return ServerFailure(message: friendly, code: code);
|
||
}
|
||
|
||
/// Drift / 文件 IO 等本地异常 → CacheFailure。
|
||
Failure fromCache(Object error, [StackTrace? st]) {
|
||
return CacheFailure(
|
||
message: '本地数据访问失败:$error',
|
||
cause: error,
|
||
stackTrace: st,
|
||
);
|
||
}
|
||
|
||
/// 兜底(任意未识别异常)。
|
||
Failure fromUnknown(Object error, [StackTrace? st]) {
|
||
if (error is Failure) return error;
|
||
if (error is DioException) return fromDio(error);
|
||
// R-ROB-2 / 2026-05-10:RSA 加密失败统一映射为 AuthFailure(cryptoFailed),
|
||
// 避免上层得到不友好的 'RsaEncryptionException: ...' 兜底文案
|
||
if (error is RsaEncryptionException) {
|
||
return AuthFailure(
|
||
message: '密码加密失败,请重试',
|
||
kind: AuthFailureKind.cryptoFailed,
|
||
cause: error,
|
||
stackTrace: st,
|
||
);
|
||
}
|
||
return UnknownFailure(
|
||
message: error.toString(),
|
||
cause: error,
|
||
stackTrace: st,
|
||
);
|
||
}
|
||
}
|