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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'exception_mapper.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
|
||||
|
||||
@ProviderFor(exceptionMapper)
|
||||
final exceptionMapperProvider = ExceptionMapperProvider._();
|
||||
|
||||
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
|
||||
|
||||
final class ExceptionMapperProvider
|
||||
extends
|
||||
$FunctionalProvider<ExceptionMapper, ExceptionMapper, ExceptionMapper>
|
||||
with $Provider<ExceptionMapper> {
|
||||
/// 全局 [ExceptionMapper] 注入点。测试时用 `overrideWithValue` 替换为 stub。
|
||||
ExceptionMapperProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'exceptionMapperProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$exceptionMapperHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ExceptionMapper> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ExceptionMapper create(Ref ref) {
|
||||
return exceptionMapper(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ExceptionMapper value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ExceptionMapper>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$exceptionMapperHash() => r'b02606666fb3e934dd54e4440c633471ab224c20';
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:sunny_mochi/core/error/exception_mapper.dart'
|
||||
show ExceptionMapper;
|
||||
|
||||
/// 应用层统一失败模型。
|
||||
///
|
||||
/// 所有数据/网络/缓存层的异常通过 [ExceptionMapper] 转换为 [Failure] 子类,
|
||||
/// presentation 层只面对 Failure,避免散落 try-catch DioException / DriftException。
|
||||
///
|
||||
/// **实现 [Exception]**:让 datasource/repository 可以直接 `throw failure;`
|
||||
/// 不触发 `only_throw_errors` lint。
|
||||
///
|
||||
/// 详见 docs/flutter-architecture-design.md §四.2。
|
||||
sealed class Failure implements Exception {
|
||||
const Failure({required this.message, this.cause, this.stackTrace});
|
||||
|
||||
final String message;
|
||||
final Object? cause;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
/// 子类自我描述前缀(避免直接用 [Object.runtimeType],Release 混淆友好)。
|
||||
String get _typeName;
|
||||
|
||||
/// 适合展示给用户的文案(区别于 [message],后者含技术细节,供 Sentry/Talker 日志使用)。
|
||||
String get userMessage;
|
||||
|
||||
@override
|
||||
String toString() => '$_typeName: $message';
|
||||
}
|
||||
|
||||
/// 网络层失败:超时 / 无网 / DNS 解析失败 / 证书绑定失败 / 连接断开。
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure({
|
||||
required super.message,
|
||||
this.kind = NetworkFailureKind.unknown,
|
||||
super.cause,
|
||||
super.stackTrace,
|
||||
});
|
||||
|
||||
final NetworkFailureKind kind;
|
||||
|
||||
@override
|
||||
String get _typeName => 'NetworkFailure';
|
||||
|
||||
@override
|
||||
String get userMessage => message;
|
||||
}
|
||||
|
||||
enum NetworkFailureKind {
|
||||
timeout,
|
||||
noNetwork,
|
||||
badCertificate,
|
||||
cancelled,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// 鉴权失败:未登录 / Token 过期 / refresh 失败 → 应跳登录。
|
||||
class AuthFailure extends Failure {
|
||||
const AuthFailure({
|
||||
required super.message,
|
||||
this.kind = AuthFailureKind.unauthorized,
|
||||
super.cause,
|
||||
super.stackTrace,
|
||||
});
|
||||
|
||||
final AuthFailureKind kind;
|
||||
|
||||
@override
|
||||
String get _typeName => 'AuthFailure';
|
||||
|
||||
@override
|
||||
String get userMessage => message;
|
||||
}
|
||||
|
||||
enum AuthFailureKind {
|
||||
unauthorized, // A0401
|
||||
forbidden, // 403
|
||||
refreshFailed,
|
||||
cryptoFailed, // 客户端加密失败(如登录密码 RSA 加密前置错)
|
||||
}
|
||||
|
||||
/// 服务端业务失败:500/501 / 业务校验失败 / 参数错误。
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure({
|
||||
required super.message,
|
||||
this.code,
|
||||
this.statusCode,
|
||||
super.cause,
|
||||
super.stackTrace,
|
||||
});
|
||||
|
||||
final String? code; // 业务错误码(如 A0500 / A0400 / A0404)
|
||||
final int? statusCode; // HTTP 状态码
|
||||
|
||||
@override
|
||||
String get _typeName => 'ServerFailure';
|
||||
|
||||
@override
|
||||
String get userMessage {
|
||||
// HTTP 5xx:服务端内部错误,后端 message 可能含技术细节(SQL/堆栈),统一遮蔽
|
||||
if (statusCode != null && statusCode! >= 500) return '服务器开小差了,请稍后再试';
|
||||
// 业务码 / 4xx:message 已经过 ResponseCode.friendlyMessage 映射,安全展示
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地缓存/数据库失败:Drift 异常 / SQLCipher 解密失败 / 文件 IO 错误。
|
||||
class CacheFailure extends Failure {
|
||||
const CacheFailure({
|
||||
required super.message,
|
||||
super.cause,
|
||||
super.stackTrace,
|
||||
});
|
||||
|
||||
@override
|
||||
String get _typeName => 'CacheFailure';
|
||||
|
||||
@override
|
||||
String get userMessage => '本地数据异常,请重启应用试试';
|
||||
}
|
||||
|
||||
/// 兜底未知错误。
|
||||
class UnknownFailure extends Failure {
|
||||
const UnknownFailure({
|
||||
required super.message,
|
||||
super.cause,
|
||||
super.stackTrace,
|
||||
});
|
||||
|
||||
@override
|
||||
String get _typeName => 'UnknownFailure';
|
||||
|
||||
@override
|
||||
String get userMessage => '遇到了点问题,已自动上报';
|
||||
}
|
||||
Reference in New Issue
Block a user