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
+53
View File
@@ -0,0 +1,53 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:sunny_mochi/core/error/failures.dart';
import 'package:sunny_mochi/core/network/response_code.dart';
part 'api_response.freezed.dart';
part 'api_response.g.dart';
@Freezed(genericArgumentFactories: true)
abstract class ApiResponse<T> with _$ApiResponse<T> {
const factory ApiResponse({
@JsonKey(name: 'code') required String retCode,
@Default('') @JsonKey(name: 'msg') String retMsg,
@JsonKey(name: 'data') T? retData,
}) = _ApiResponse;
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object?) fromJsonT,
) => _$ApiResponseFromJson(json, fromJsonT);
}
extension ApiResponseX<T> on ApiResponse<T> {
bool get isSuccess => ResponseCode.isSuccess(retCode);
bool get isTokenExpired => ResponseCode.isUnauthorized(retCode);
T? unwrap() {
if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode);
return retData;
}
void unwrapVoid() {
if (!isSuccess) throw ServerFailure(message: retMsg, code: retCode);
}
T unwrapRequired([String errorMsg = '响应数据为空']) {
final data = unwrap();
if (data == null) throw ServerFailure(message: errorMsg);
return data;
}
}
/// Datasource 层 envelope 解析入口。
///
/// 用法:`parseEnvelope(resp.data, (j) => MyModel.fromJson(j))?.unwrap()`
ApiResponse<T> parseEnvelope<T>(
Map<String, dynamic>? body,
T Function(Object?) fromJsonT, {
String emptyMsg = '响应为空',
}) {
if (body == null) throw ServerFailure(message: emptyMsg);
return ApiResponse.fromJson(body, fromJsonT);
}
+283
View File
@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'api_response.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ApiResponse<T> {
@JsonKey(name: 'code') String get retCode;@JsonKey(name: 'msg') String get retMsg;@JsonKey(name: 'data') T? get retData;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ApiResponseCopyWith<T, ApiResponse<T>> get copyWith => _$ApiResponseCopyWithImpl<T, ApiResponse<T>>(this as ApiResponse<T>, _$identity);
/// Serializes this ApiResponse to a JSON map.
Map<String, dynamic> toJson(Object? Function(T) toJsonT);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ApiResponse<T>&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData));
@override
String toString() {
return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)';
}
}
/// @nodoc
abstract mixin class $ApiResponseCopyWith<T,$Res> {
factory $ApiResponseCopyWith(ApiResponse<T> value, $Res Function(ApiResponse<T>) _then) = _$ApiResponseCopyWithImpl;
@useResult
$Res call({
@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData
});
}
/// @nodoc
class _$ApiResponseCopyWithImpl<T,$Res>
implements $ApiResponseCopyWith<T, $Res> {
_$ApiResponseCopyWithImpl(this._self, this._then);
final ApiResponse<T> _self;
final $Res Function(ApiResponse<T>) _then;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) {
return _then(_self.copyWith(
retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable
as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable
as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable
as T?,
));
}
}
/// Adds pattern-matching-related methods to [ApiResponse].
extension ApiResponsePatterns<T> on ApiResponse<T> {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ApiResponse<T> value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ApiResponse<T> value) $default,){
final _that = this;
switch (_that) {
case _ApiResponse():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ApiResponse<T> value)? $default,){
final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData) $default,) {final _that = this;
switch (_that) {
case _ApiResponse():
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(name: 'code') String retCode, @JsonKey(name: 'msg') String retMsg, @JsonKey(name: 'data') T? retData)? $default,) {final _that = this;
switch (_that) {
case _ApiResponse() when $default != null:
return $default(_that.retCode,_that.retMsg,_that.retData);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable(genericArgumentFactories: true)
class _ApiResponse<T> implements ApiResponse<T> {
const _ApiResponse({@JsonKey(name: 'code') required this.retCode, @JsonKey(name: 'msg') this.retMsg = '', @JsonKey(name: 'data') this.retData});
factory _ApiResponse.fromJson(Map<String, dynamic> json,T Function(Object?) fromJsonT) => _$ApiResponseFromJson(json,fromJsonT);
@override@JsonKey(name: 'code') final String retCode;
@override@JsonKey(name: 'msg') final String retMsg;
@override@JsonKey(name: 'data') final T? retData;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ApiResponseCopyWith<T, _ApiResponse<T>> get copyWith => __$ApiResponseCopyWithImpl<T, _ApiResponse<T>>(this, _$identity);
@override
Map<String, dynamic> toJson(Object? Function(T) toJsonT) {
return _$ApiResponseToJson<T>(this, toJsonT);
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ApiResponse<T>&&(identical(other.retCode, retCode) || other.retCode == retCode)&&(identical(other.retMsg, retMsg) || other.retMsg == retMsg)&&const DeepCollectionEquality().equals(other.retData, retData));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,retCode,retMsg,const DeepCollectionEquality().hash(retData));
@override
String toString() {
return 'ApiResponse<$T>(retCode: $retCode, retMsg: $retMsg, retData: $retData)';
}
}
/// @nodoc
abstract mixin class _$ApiResponseCopyWith<T,$Res> implements $ApiResponseCopyWith<T, $Res> {
factory _$ApiResponseCopyWith(_ApiResponse<T> value, $Res Function(_ApiResponse<T>) _then) = __$ApiResponseCopyWithImpl;
@override @useResult
$Res call({
@JsonKey(name: 'code') String retCode,@JsonKey(name: 'msg') String retMsg,@JsonKey(name: 'data') T? retData
});
}
/// @nodoc
class __$ApiResponseCopyWithImpl<T,$Res>
implements _$ApiResponseCopyWith<T, $Res> {
__$ApiResponseCopyWithImpl(this._self, this._then);
final _ApiResponse<T> _self;
final $Res Function(_ApiResponse<T>) _then;
/// Create a copy of ApiResponse
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? retCode = null,Object? retMsg = null,Object? retData = freezed,}) {
return _then(_ApiResponse<T>(
retCode: null == retCode ? _self.retCode : retCode // ignore: cast_nullable_to_non_nullable
as String,retMsg: null == retMsg ? _self.retMsg : retMsg // ignore: cast_nullable_to_non_nullable
as String,retData: freezed == retData ? _self.retData : retData // ignore: cast_nullable_to_non_nullable
as T?,
));
}
}
// dart format on
+35
View File
@@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ApiResponse<T> _$ApiResponseFromJson<T>(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) => _ApiResponse<T>(
retCode: json['code'] as String,
retMsg: json['msg'] as String? ?? '',
retData: _$nullableGenericFromJson(json['data'], fromJsonT),
);
Map<String, dynamic> _$ApiResponseToJson<T>(
_ApiResponse<T> instance,
Object? Function(T value) toJsonT,
) => <String, dynamic>{
'code': instance.retCode,
'msg': instance.retMsg,
'data': _$nullableGenericToJson(instance.retData, toJsonT),
};
T? _$nullableGenericFromJson<T>(
Object? input,
T Function(Object? json) fromJson,
) => input == null ? null : fromJson(input);
Object? _$nullableGenericToJson<T>(
T? input,
Object? Function(T value) toJson,
) => input == null ? null : toJson(input);
+56
View File
@@ -0,0 +1,56 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/api_config.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sunny_mochi/core/network/interceptors/auth_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/auth_logout_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/cert_pinning_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/error_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/log_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/retry_interceptor.dart';
import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart';
import 'package:sunny_mochi/core/network/mock/dio_mock_adapter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'dio_client.g.dart';
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
@Riverpod(keepAlive: true)
Dio dioClient(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: ApiConfig.connectTimeout,
receiveTimeout: ApiConfig.receiveTimeout,
sendTimeout: ApiConfig.sendTimeout,
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
dio.interceptors.addAll([
CertPinningInterceptor(),
AuthInterceptor(ref),
TokenRefreshInterceptor(ref, dio),
RetryInterceptor(dio),
ErrorInterceptor(),
AuthLogoutInterceptor(ref),
buildNetworkLogInterceptor(),
]);
if (Env.useMock) {
dio.httpClientAdapter = buildDefaultMockAdapter();
}
return dio;
}
+81
View File
@@ -0,0 +1,81 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dio_client.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
@ProviderFor(dioClient)
final dioClientProvider = DioClientProvider._();
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
final class DioClientProvider extends $FunctionalProvider<Dio, Dio, Dio>
with $Provider<Dio> {
/// 完整 7 拦截器栈:
/// CertPinning → Auth → TokenRefresh → Retry → Error → AuthLogout → Log
///
/// 顺序敏感:
/// - CertPinning 最早(在请求出去前校验)
/// - Auth 注 TokenTokenRefresh 在 401 时拦截再发
/// - Retry 在网络/5xx 错误时退避重试
/// - Error 把所有 DioException 映射为应用层 Failure
/// - **AuthLogout 必须在 Error 之后**:依赖 e.error 已被映射为 AuthFailure
/// - Log 最后,记录最终结果(Release 包 noop
DioClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dioClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dioClientHash();
@$internal
@override
$ProviderElement<Dio> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Dio create(Ref ref) {
return dioClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Dio value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Dio>(value),
);
}
}
String _$dioClientHash() => r'f740b06528c313c24f8288686585bdf379411fa2';
@@ -0,0 +1,37 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/network/interceptors/token_refresh_interceptor.dart'
show TokenRefreshInterceptor;
import 'package:sunny_mochi/core/storage/secure_storage.dart'
show secureStorageProvider;
/// 自动注入 sa-token 鉴权 header。
///
/// **后端是 sa-token 框架** — header 名是动态的(登录响应 `saTokenInfo.tokenName` 返回,
/// 如 `satoken`),不是固定的 `Authorization: Bearer xxx`。
/// Token 失效(401)的处理由 [TokenRefreshInterceptor] 接管,本拦截器只管注入。
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
final Ref _ref;
/// 兜底 header 名 — 当 SecureStorage 里没有 tokenName 时使用 sa-token 框架默认值。
static const String _fallbackTokenName = 'satoken';
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final skipAuth = options.extra['skip_auth'] == true;
if (skipAuth) return handler.next(options);
final storage = _ref.read(secureStorageProvider);
final token = await storage.getToken();
if (token != null && token.isNotEmpty) {
final tokenName = await storage.getTokenName() ?? _fallbackTokenName;
options.headers[tokenName] = token;
}
handler.next(options);
}
}
@@ -0,0 +1,46 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/error/failures.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:sunny_mochi/core/storage/secure_storage.dart'
show secureStorageProvider;
import 'package:sunny_mochi/features/auth/presentation/notifiers/auth_status_provider.dart';
/// 全局 AuthFailure → markLoggedOut 副作用拦截器。
///
/// 链路:
/// 1. ErrorInterceptor 已经把 [DioException] 映射为 [Failure] 写回 `e.error`
/// 2. 本拦截器(必须放在 ErrorInterceptor **之后**)检查 `e.error`
/// 3. 命中 [AuthFailure] 的 unauthorized / refreshFailed 时:
/// - 清空 SecureStorage
/// - markLoggedOut → router refreshListenable 触发 → 自动跳 /login
/// 4. forbidden 不清登录态,仅由 UI 处理
class AuthLogoutInterceptor extends Interceptor {
AuthLogoutInterceptor(this._ref);
final Ref _ref;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final failure = err.error;
if (failure is AuthFailure && _shouldLogout(failure.kind)) {
appTalker.warning(
'[AuthLogout] AuthFailure(${failure.kind.name}) → markLoggedOut + clearAll',
);
try {
await _ref.read(secureStorageProvider).clearAll();
} on Object catch (e, st) {
appTalker.warning('[AuthLogout] clearAll 失败', e, st);
}
_ref.read(authStatusProvider).markLoggedOut();
}
handler.next(err);
}
bool _shouldLogout(AuthFailureKind kind) =>
kind == AuthFailureKind.unauthorized ||
kind == AuthFailureKind.refreshFailed;
}
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
/// TLS 证书绑定(Pinning)拦截器。
///
/// 当前为**占位实现**[Env.pinnedFingerprints] 为空时跳过校验,
/// 非空时由 HttpClientAdapter 层做真实 SHA-256 比对。
/// 生产指纹在环境就绪后替换 Dio.httpClientAdapter 接入。
class CertPinningInterceptor extends Interceptor {
CertPinningInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (Env.pinnedFingerprints.isEmpty) {
return handler.next(options);
}
appTalker.verbose(
'[CertPinning] ${options.uri.host} → 准备校验(${Env.pinnedFingerprints.length} 指纹)',
);
handler.next(options);
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/error/exception_mapper.dart';
import 'package:sunny_mochi/core/error/failures.dart' show Failure;
/// 把 [DioException] 映射为应用层 [Failure],写回 `error.error` 字段。
///
/// 后续上层只需 catch DioException 然后读 `e.error as Failure`
/// 或者在 Repository 层 catch DioException 后调用 [ExceptionMapper.fromDio]。
class ErrorInterceptor extends Interceptor {
ErrorInterceptor({ExceptionMapper? mapper})
: _mapper = mapper ?? const ExceptionMapper();
final ExceptionMapper _mapper;
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final failure = _mapper.fromDio(err);
handler.next(
err.copyWith(
error: failure,
message: failure.message,
),
);
}
}
@@ -0,0 +1,13 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/env.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
/// 网络请求日志:仅在 [Env.enableDevPanel]debug 或内测包)启用,
/// Release 包不记录请求/响应 body(含敏感数据)。
Interceptor buildNetworkLogInterceptor() {
if (!Env.enableDevPanel) {
return InterceptorsWrapper();
}
return TalkerDioLogger(talker: appTalker);
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
/// 408/429/500/502/503/504 指数退避重试,最多 [maxRetries] 次。
class RetryInterceptor extends Interceptor {
RetryInterceptor(this._dio, {this.maxRetries = 3});
final Dio _dio;
final int maxRetries;
static const _retryStatuses = {408, 429, 500, 502, 503, 504};
static const Set<DioExceptionType> _retryDioTypes = {
DioExceptionType.connectionTimeout,
DioExceptionType.receiveTimeout,
DioExceptionType.sendTimeout,
DioExceptionType.connectionError,
};
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final status = err.response?.statusCode;
final attempt = (err.requestOptions.extra['_retry_attempt'] as int?) ?? 0;
final shouldRetry =
(status != null && _retryStatuses.contains(status)) ||
_retryDioTypes.contains(err.type);
if (!shouldRetry || attempt >= maxRetries) {
return handler.next(err);
}
// 指数退避:200ms → 400ms → 800ms
final delayMs = 200 * (1 << attempt);
appTalker.info(
'[Retry] ${err.requestOptions.uri.path}${attempt + 1}/$maxRetries 次重试(${delayMs}ms 后)',
);
await Future<void>.delayed(Duration(milliseconds: delayMs));
try {
final response = await _dio.fetch<dynamic>(
err.requestOptions..extra['_retry_attempt'] = attempt + 1,
);
return handler.resolve(response);
} on DioException catch (e) {
return handler.next(e);
}
}
}
@@ -0,0 +1,141 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sunny_mochi/core/config/api_paths.dart';
import 'package:sunny_mochi/core/network/response_code.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
import 'package:sunny_mochi/core/storage/secure_storage.dart'
show secureStorageProvider;
/// 401 时尝试 refresh 一次 → 重放原请求;refresh 也失败才清空 token。
///
/// **互斥实现**:用 [Completer] 显式表达互斥语义,避免并发 401 重复消耗 RefreshToken
/// - 第一个 401 进入 → new Completer + 把 future 缓存在 [_refreshing]
/// - 后续并发 401 → await 同一个 [_refreshing] 的 future
/// - refresh 完成后 [_refreshing] 清空,新一轮 401 才会再次触发
///
/// **业务码分类**
/// - status==401 + body.retCode ∈ token 类码 → refresh + 重放
/// - status==401 + body.retCode 是其他业务码(如权限不足)→ 透传,不消耗 RefreshToken
/// - status==401 + 无 body 或无 retCode → 透传,不消耗 RefreshToken
class TokenRefreshInterceptor extends Interceptor {
TokenRefreshInterceptor(this._ref, this._dio);
final Ref _ref;
final Dio _dio;
Completer<bool>? _refreshing;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final status = err.response?.statusCode;
final retried = err.requestOptions.extra['_token_refreshed'] == true;
final isAuthEndpoint = err.requestOptions.extra['skip_auth'] == true;
if (status != 401 || retried || isAuthEndpoint) {
return handler.next(err);
}
final retCode = _extractRetCode(err.response?.data);
if (!ResponseCode.isTokenRefreshable(retCode)) {
err.requestOptions.extra['_auth_not_refreshable'] = true;
return handler.next(err);
}
final storage = _ref.read(secureStorageProvider);
final refreshToken = await storage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
await storage.clearAll();
return handler.next(err);
}
final pending = _refreshing;
final bool ok;
if (pending != null) {
ok = await pending.future;
} else {
final completer = Completer<bool>();
_refreshing = completer;
bool result = false;
try {
result = await _runRefresh(refreshToken);
} finally {
// 先清空 _refreshing,再 complete
// 确保新到来的 401 能进入下一轮 refresh,而当前 waiters 仍通过
// 已持有的 completer 引用得到结果(不受 _refreshing = null 影响)。
_refreshing = null;
completer.complete(result);
}
ok = result;
}
if (!ok) {
err.requestOptions.extra['_auth_refresh_failed'] = true;
return handler.next(err);
}
try {
final newToken = await storage.getToken();
if (newToken != null && newToken.isNotEmpty) {
final tokenName = await storage.getTokenName() ?? 'satoken';
err.requestOptions.headers[tokenName] = newToken;
}
final retriedResp = await _dio.fetch<dynamic>(
err.requestOptions..extra['_token_refreshed'] = true,
);
return handler.resolve(retriedResp);
} on DioException catch (e) {
return handler.next(e);
}
}
Future<bool> _runRefresh(String refreshToken) async {
try {
return await _doRefresh(refreshToken);
} on Object catch (e, st) {
appTalker.warning('[TokenRefresh] 刷新失败:$e', e, st);
await _ref.read(secureStorageProvider).clearAll();
return false;
}
}
Future<bool> _doRefresh(String refreshToken) async {
final resp = await _dio.post<Map<String, dynamic>>(
ApiPaths.authRefresh,
data: {'refresh_token': refreshToken},
options: Options(extra: {'skip_auth': true}),
);
final body = resp.data;
final retCode = body?['code'] as String? ?? body?['retCode'] as String?;
if (retCode != '00000') return false;
final retData =
(body?['data'] ?? body?['retData']) as Map<String, dynamic>?;
final access = retData?['access_token'] as String?;
final refresh = retData?['refresh_token'] as String?;
if (access == null || access.isEmpty) return false;
final storage = _ref.read(secureStorageProvider);
await storage.setToken(access);
if (refresh != null && refresh.isNotEmpty) {
await storage.setRefreshToken(refresh);
}
// tokenName 可能随 refresh 响应更新(对齐登录路径 auth_repository_impl._persistUser
final newTokenName = retData?['token_name'] as String?
?? retData?['tokenName'] as String?;
if (newTokenName != null && newTokenName.isNotEmpty) {
await storage.setTokenName(newTokenName);
}
return true;
}
String? _extractRetCode(Object? body) {
if (body is! Map) return null;
final raw = body['retCode'] ?? body['code'] ?? body['errorCode'];
return raw is String ? raw : raw?.toString();
}
}
@@ -0,0 +1,99 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:sunny_mochi/core/config/api_paths.dart';
import 'package:sunny_mochi/core/config/env.dart' show Env;
import 'package:sunny_mochi/core/network/mock/mock_response_loader.dart';
import 'package:sunny_mochi/core/observability/talker_setup.dart';
/// 把 Dio 请求拦截,根据 path + method 返回 fixtures 中的预设响应。
///
/// 启用方式:在 dio_client.dart 中检查 [Env.useMock]
/// ```dart
/// if (Env.useMock) {
/// dio.httpClientAdapter = buildDefaultMockAdapter();
/// }
/// ```
class DioMockAdapter implements HttpClientAdapter {
DioMockAdapter({this.delay = const Duration(milliseconds: 200)});
final Map<String, String> _routes = {};
final Duration delay;
bool _closed = false;
/// 注册一条 mock 路由:method+path → fixtures 文件名(不含 .json 后缀)。
void register(String method, String path, String fixtureName) {
_routes['${method.toUpperCase()} $path'] = fixtureName;
}
@override
void close({bool force = false}) {
_closed = true;
}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
if (_closed) {
throw DioException(requestOptions: options, message: 'adapter closed');
}
await Future<void>.delayed(delay);
final key = '${options.method} ${options.path}';
final exact = _routes[key];
var matched = exact;
if (matched == null) {
for (final entry in _routes.entries) {
if (key.startsWith(entry.key)) {
matched = entry.value;
break;
}
}
}
if (matched == null) {
appTalker.warning('[Mock] 未注册 fixture$key — 返回空 envelope');
final body = jsonEncode({
'code': '00000',
'msg': 'mock-empty',
'data': null,
});
return ResponseBody.fromString(
body,
200,
headers: {'content-type': ['application/json']},
);
}
appTalker.verbose('[Mock] $key → fixtures/$matched.json');
final json = await MockResponseLoader.load(matched);
final body = jsonEncode(json);
return ResponseBody.fromString(
body,
200,
headers: {'content-type': ['application/json']},
);
}
}
/// 默认的全局 mock 路由表。
// TODO: 按项目实际 API 路径扩展此路由表
DioMockAdapter buildDefaultMockAdapter() {
return DioMockAdapter()
// === Auth ===
..register('POST', ApiPaths.authLoginSms, 'auth/login_success')
..register('POST', ApiPaths.authLoginPwd, 'auth/login_success')
..register('POST', ApiPaths.authSmsSend, 'auth/send_sms_code_success')
..register('POST', ApiPaths.authRefresh, 'auth/refresh_success')
..register('POST', ApiPaths.crashReport, 'common/generic_success')
// === User / Mine ===
..register('GET', ApiPaths.userProfile, 'mine/profile')
..register('POST', ApiPaths.userChangePassword, 'common/generic_success')
..register('POST', ApiPaths.userAvatarUpload, 'mine/avatar_upload');
}
@@ -0,0 +1,21 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
/// 从 assets/fixtures/ 加载 JSON 响应。
///
/// 使用:
/// ```dart
/// final body = await MockResponseLoader.load('auth/login_success');
/// ```
abstract class MockResponseLoader {
static final Map<String, Map<String, dynamic>> _cache = {};
static Future<Map<String, dynamic>> load(String name) async {
if (_cache.containsKey(name)) return _cache[name]!;
final raw = await rootBundle.loadString('assets/fixtures/$name.json');
final json = jsonDecode(raw) as Map<String, dynamic>;
_cache[name] = json;
return json;
}
}
+25
View File
@@ -0,0 +1,25 @@
sealed class NetworkException implements Exception {
const NetworkException(this.message);
final String message;
}
class NoNetworkException extends NetworkException {
const NoNetworkException() : super('网络不可用,请检查网络连接');
}
class TimeoutException extends NetworkException {
const TimeoutException() : super('请求超时,请稍后重试');
}
class ServerException extends NetworkException {
const ServerException([super.msg = '服务器异常,请稍后重试']);
}
class UnauthorizedException extends NetworkException {
const UnauthorizedException() : super('登录已过期,请重新登录');
}
class ApiException extends NetworkException {
const ApiException(super.message, this.code);
final String code;
}
+50
View File
@@ -0,0 +1,50 @@
/// 服务端业务错误码常量。
///
/// envelope 结构:`{retCode: String, retMsg: String, retData: T?}`key map: code/msg/data
/// 错误码格式:`A04XX` = 客户端错 / `A05XX` = 服务端错 / `99999` = 兜底 / `00000` = 成功
abstract class ResponseCode {
/// 成功
static const String success = '00000';
/// 兜底请求失败
static const String generic = '99999';
/// 服务端 500 类
static const String serverError = 'A0500';
static const String systemError = 'A0501';
/// 客户端 400 类
static const String paramError = 'A0400';
static const String paramMissing = 'A0402';
static const String resourceNotFound = 'A0404';
/// 鉴权过期 → 跳登录
static const String unauthorized = 'A0401';
/// Token 类业务码(占位 — 待后端确认实际值后改这里一处)。
// TODO: 后端确认后更新 tokenExpired / tokenInvalid 实际字符串值
static const String tokenExpired = 'TOKEN_EXPIRED';
static const String tokenInvalid = 'TOKEN_INVALID';
/// 仅 token 类业务码才允许 TokenRefreshInterceptor 触发 refresh。
static bool isTokenRefreshable(String? code) =>
code == unauthorized || code == tokenExpired || code == tokenInvalid;
static bool isSuccess(String? code) => code == success;
static bool isUnauthorized(String? code) => code == unauthorized;
/// 用户友好的错误提示。
static String friendlyMessage(String? code, String fallback) {
return switch (code) {
success => '',
unauthorized => '登录已过期,请重新登录',
paramError => '请求参数有误,请稍候重试',
paramMissing => '请求参数不完整,请稍候重试',
resourceNotFound => '请求的资源不存在',
serverError || systemError => '服务器开小差啦,请稍候重试',
generic => '请求失败',
_ => fallback,
};
}
}