feat(android): 支持沉浸式状态栏、地图定位与自定义签名
- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配 - 新增百度地图权限、AK 配置及定位权限说明 - 接入 MethodChannel 实现返回键退后台热启动优化 - 配置自定义签名并更新构建逻辑 - 完善 token 刷新与鉴权失效统一处理 - 新增地图页与截图页路由 - 修复返回键拦截导致的 PopScope 失效问题
This commit is contained in:
@@ -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)',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user