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:
SkyJourney
2026-05-14 12:51:05 +08:00
commit 61017f1c39
204 changed files with 15386 additions and 0 deletions
+161
View File
@@ -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/TalkeruserMessage 由 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-10RSA 加密失败统一映射为 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,
);
}
}