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
@@ -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();
}
}