feat(android): 支持沉浸式状态栏、地图定位与自定义签名

- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配
- 新增百度地图权限、AK 配置及定位权限说明
- 接入 MethodChannel 实现返回键退后台热启动优化
- 配置自定义签名并更新构建逻辑
- 完善 token 刷新与鉴权失效统一处理
- 新增地图页与截图页路由
- 修复返回键拦截导致的 PopScope 失效问题
This commit is contained in:
TongTongStudio
2026-08-19 03:43:12 +08:00
parent 41d815336a
commit 06cdc77172
51 changed files with 3988 additions and 634 deletions

View File

@@ -54,6 +54,15 @@ class AppError {
);
}
if (status != null) {
// 鉴权失效统一映射为 unauthorized 类型。
if (status == 401) {
return AppError._(
type: AppErrorType.unauthorized,
message: '登录已失效,请重新登录',
statusCode: status,
original: e,
);
}
return AppError._(
type: AppErrorType.server,
message: '服务暂时不可用HTTP $status',

View File

@@ -48,6 +48,87 @@ class DioClient {
return dio;
}
/// 专用于刷新 token 的独立 Dio 实例。
///
/// 不带任何业务/鉴权拦截器,避免刷新请求自身递归进入 401 流程,
/// 也避免被 [_AuthInterceptor] 注入已失效的旧 token。
static Dio get _refreshDio {
final dio = Dio(
BaseOptions(
baseUrl: AppConstants.kBaseUrl,
connectTimeout:
Duration(milliseconds: AppConstants.kConnectTimeoutMs),
receiveTimeout:
Duration(milliseconds: AppConstants.kReceiveTimeoutMs),
headers: {'Content-Type': 'application/json'},
),
);
return dio;
}
/// 标记当前是否正在刷新,避免并发 401 引发多次刷新。
static bool _isRefreshing = false;
/// 触发一次 token 刷新并重放原请求。
///
/// 返回重放后的响应体(已解析 data刷新失败则抛出 [ApiException]
/// unauthorized由上层触发登出清理。
static Future<dynamic> _refreshAndRetry(
Future<Response<dynamic>> Function() call,
) async {
if (_isRefreshing) {
// 已有刷新在途:直接等待其完成并重放,避免并发刷新。
// 简单处理为失败,交由上层登出,避免复杂队列。
throw const ApiException(code: '401', message: '登录已失效,请重新登录');
}
_isRefreshing = true;
try {
final refreshed = await _refreshToken();
if (!refreshed) {
TokenStorage.clear();
throw const ApiException(code: '401', message: '登录已失效,请重新登录');
}
// 刷新成功:用新 token 重放原请求。
final response = await call();
return _ResponseInterceptor.parse(response);
} finally {
_isRefreshing = false;
}
}
/// 使用 refreshToken 换取新 token并写回本地存储。
///
/// 返回是否刷新成功。刷新接口为匿名接口(带 refreshToken query
/// 使用独立 [_refreshDio],不触发任何拦截器。
static Future<bool> _refreshToken() async {
final refreshToken = TokenStorage.refreshToken;
debugPrint('[DioClient] _refreshToken: start, hasRefreshToken=${refreshToken?.isNotEmpty}');
if (refreshToken == null || refreshToken.isEmpty) return false;
try {
// 对接 open 模块刷新接口,路径需带 v1/open/ 前缀。
final response = await _refreshDio.post<dynamic>(
'v1/open/refresh-token',
queryParameters: {'refreshToken': refreshToken},
);
debugPrint('[DioClient] _refreshToken: response status=${response.statusCode} body=${response.data}');
final body = response.data as Map<String, dynamic>?;
if (body == null) return false;
final code = body['code']?.toString() ?? '-1';
// 成功码约定为 "00000"。
if (code != '00000') return false;
final data = body['data'] as Map<String, dynamic>?;
final newToken = data?['accessToken'] as String?;
final newRefresh = data?['refreshToken'] as String?;
if (newToken == null || newToken.isEmpty) return false;
TokenStorage.saveTokens(token: newToken, refreshToken: newRefresh);
debugPrint('[DioClient] _refreshToken: success, newToken=${newToken.substring(0, 5)}...');
return true;
} catch (e) {
debugPrint('[DioClient] _refreshToken: unexpected error: $e');
return false;
}
}
/// 统一 GET 请求,返回解析后的 data。
static Future<dynamic> get(
String path, {
@@ -80,6 +161,9 @@ class DioClient {
/// 统一请求入口:发起请求,错误由拦截器链转换为 [ApiException],此处再包成
/// [AppError] 以便调用方按需分类处理。Repository 直接捕获 [ApiException] 即可。
///
/// 401 / 令牌失效处理无论后台以「HTTP 401 + 业务码」还是纯业务码表达令牌失效,
/// 均在此统一捕获并尝试刷新 token 后重试一次;刷新失败抛出 unauthorized 错误。
static Future<dynamic> _request(
Future<Response<dynamic>> Function() call,
) async {
@@ -92,8 +176,19 @@ class DioClient {
'statusCode=${response.statusCode}',
);
debugPrint('[DioClient] response body=${response.data}');
// 解析成功返回 data若为业务错误code != "00000")会在此抛出 ApiException。
return _ResponseInterceptor.parse(response);
} on ApiException {
} on ApiException catch (e) {
// A0300端标识/权限异常,旧令牌无法通过刷新恢复 client 身份,
// 直接清理本地令牌并转为登录失效,引导重新登录。
if (e.code == 'A0300') {
TokenStorage.clear();
throw const ApiException(code: '401', message: '登录已失效,请重新登录');
}
// 业务层已抛出的异常:判断是否令牌失效,触发刷新重试。
if (_isTokenExpired(e, null)) {
return _refreshAndRetry(call);
}
rethrow;
} on DioException catch (e) {
debugPrint(
@@ -102,6 +197,12 @@ class DioClient {
'type=${e.type} message=${e.message}',
);
debugPrint('[DioClient] error response body=${e.response?.data}');
// 拦截器已将端标识/权限异常包装为 ApiException如登录失效直接透传。
if (e.error is ApiException) {
throw e.error! as ApiException;
}
// 注HTTP 401 的刷新重试已在 [_ErrorInterceptor.onError] 统一处理,
// 此处不再重复,避免重复刷新。
throw ApiException.fromAppError(
AppError.fromDioException(e),
);
@@ -110,12 +211,29 @@ class DioClient {
throw ApiException.fromAppError(AppError.unknown(e));
}
}
/// 判断业务异常是否为令牌失效,需触发刷新。
///
/// 兼容两类表达HTTP 401 已在调用处单独判断;此处针对响应体业务码
/// (如后台用 {code: A0230, msg: 访问令牌无效或已过期} 表达令牌过期)。
static bool _isTokenExpired(ApiException e, int? statusCode) {
if (statusCode == 401) return true;
// 已知令牌失效业务码(与后台约定,按需补充)。
const expiredCodes = {'A0230', '401', 'A0801'};
return expiredCodes.contains(e.code);
}
}
/// 请求拦截器:自动为已登录会话注入 Authorization 头。
class _AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// [DEBUG] 打印所有接口请求的完整地址debugPrint 在 release 下为空实现)。
if (kDebugMode) {
debugPrint(
'[DioClient] -> ${options.method} ${options.uri}',
);
}
final token = TokenStorage.accessToken;
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
@@ -155,73 +273,99 @@ class _ResponseInterceptor extends Interceptor {
}
}
/// 错误拦截器:将 [DioException] 统一转换为 [ApiException],并处理 401 刷新
/// 错误拦截器:统一处理 HTTP 401 自动刷新 token 并重试
///
/// 所有请求get/post/put/delete/multipart 等)的 401 响应均在此拦截,
/// 触发刷新后自动重放原请求;刷新失败则向下传递 401 由上层登出处理。
/// 这样无论哪个请求方法都不会遗漏 401 刷新逻辑。
class _ErrorInterceptor extends Interceptor {
/// 标记当前是否正在刷新,避免并发 401 引发多次刷新。
static bool _isRefreshing = false;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// HTTP 401尝试刷新 token 并重试原请求一次。
if (err.response?.statusCode == 401 && !_isRefreshing) {
try {
_isRefreshing = true;
final refreshed = await _refreshToken();
if (refreshed) {
final clone = await _retry(err.requestOptions);
handler.resolve(clone);
return;
}
} catch (_) {
// 刷新失败:继续向下抛出 unauthorized。
} finally {
_isRefreshing = false;
}
void onError(DioException err, ErrorInterceptorHandler handler) {
final response = err.response;
final statusCode = response?.statusCode;
final body = response?.data as Map<String, dynamic>?;
final code = body?['code']?.toString();
// 访问权限异常A0300通常意味着当前令牌的端标识不符如旧令牌未携带
// clientType或跨端令牌。此类令牌无法通过刷新恢复客户端身份直接清理
// 本地令牌并转为「登录失效」,由上层引导重新登录,避免反复提示权限异常。
if (code == 'A0300') {
debugPrint(
'[DioClient] _ErrorInterceptor: 端标识/权限异常 '
'(status=$statusCode, code=$code). Clearing token & require re-login.',
);
TokenStorage.clear();
handler.next(_asLoginExpired(err));
return;
}
// 统一判断是否为令牌失效HTTP 401 或业务码 A0230/A0801 等)。
if (_isTokenExpiredRaw(code, statusCode)) {
debugPrint(
'[DioClient] _ErrorInterceptor: Token expired detected '
'(status=$statusCode, code=$code). Attempting refresh...',
);
_handle401(err, handler);
return;
}
handler.next(err);
}
/// 使用 refreshToken 换取新 token并写回本地存储
/// 将权限/端标识异常包装为「登录失效」错误,供上层统一走重新登录流程
static DioException _asLoginExpired(DioException err) {
return DioException(
requestOptions: err.requestOptions,
response: err.response,
type: DioExceptionType.unknown,
error: const ApiException(code: '401', message: '登录已失效,请重新登录'),
);
}
/// 内部快速判断逻辑,逻辑同 [DioClient._isTokenExpired]。
bool _isTokenExpiredRaw(String? code, int? statusCode) {
if (statusCode == 401) return true;
const expiredCodes = {'A0230', '401', 'A0801'};
return code != null && expiredCodes.contains(code);
}
/// 处理 HTTP 401刷新 token 后重放原请求。
///
/// 返回是否刷新成功。具体刷新接口路径以后台约定为准。
static Future<bool> _refreshToken() async {
final refreshToken = TokenStorage.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) return false;
/// 刷新受 [DioClient._isRefreshing] 保护(并发 401 只刷新一次)。重放通过
/// [DioClient.instance.fetch] 进行,会自动重新注入新 token 并再次经过
/// 拦截器链。刷新失败则向下传递原始 401。
static void _handle401(
DioException err,
ErrorInterceptorHandler handler,
) async {
try {
final response = await DioClient.instance.post<dynamic>(
'/refresh-token',
queryParameters: {'refreshToken': refreshToken},
);
final data = response as Map<String, dynamic>?;
final newToken = data?['accessToken'] as String?;
final newRefresh = data?['refreshToken'] as String?;
if (newToken == null || newToken.isEmpty) return false;
TokenStorage.saveTokens(
token: newToken,
refreshToken: newRefresh,
);
return true;
final refreshed = await _refreshTokenOnce();
if (!refreshed) {
TokenStorage.clear();
handler.next(err);
return;
}
final response = await DioClient.instance.fetch(err.requestOptions);
handler.resolve(response);
} catch (_) {
return false;
handler.next(err);
}
}
/// 用原请求参数重放一次请求
static Future<Response<dynamic>> _retry(RequestOptions options) {
return DioClient.instance.request<dynamic>(
options.path,
data: options.data,
queryParameters: options.queryParameters,
options: Options(
method: options.method,
headers: {
...options.headers,
'Authorization': 'Bearer ${TokenStorage.accessToken}',
},
),
);
/// 刷新 token带并发保护
///
/// 返回是否刷新成功。刷新接口为匿名接口,使用独立 [_refreshDio]
/// 不触发任何拦截器,避免递归进入 401 流程。
static Future<bool> _refreshTokenOnce() async {
debugPrint('[DioClient] _refreshTokenOnce: isRefreshing=${DioClient._isRefreshing}');
if (DioClient._isRefreshing) {
// 已有刷新在途:简单处理为失败,交由上层登出,避免复杂队列。
return false;
}
DioClient._isRefreshing = true;
try {
return await DioClient._refreshToken();
} finally {
DioClient._isRefreshing = false;
}
}
}