Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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
|
||||
///
|
||||
/// 详见 docs/flutter-architecture-design.md §四.2。
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user