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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user