first commit
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// API 环境配置
|
||||
///
|
||||
/// ## 开发环境(按平台自动选择)
|
||||
/// - **Android 真机/模拟器**:`http://localhost:12345/api`
|
||||
/// 需要先做端口转发,把手机的 localhost 映射到电脑的 localhost:
|
||||
/// ```
|
||||
/// adb reverse tcp:12345 tcp:12345 # 每次插拔设备后需重新执行
|
||||
/// adb reverse --list # 确认映射存在
|
||||
/// ```
|
||||
/// 好处:不受 Windows 防火墙影响,也不要求手机与电脑在同一 Wi-Fi。
|
||||
/// 若不想用 adb reverse(仅模拟器可行),可用电脑局域网 IP 覆盖:
|
||||
/// `flutter run --dart-define=API_BASE_URL=http://192.168.x.x:12345/api`
|
||||
/// (模拟器也可直接用固定地址 `10.0.2.2:12345`)
|
||||
/// - **Windows / Chrome / iOS 模拟器**:`http://localhost:12345/api`
|
||||
///
|
||||
/// ## 生产环境
|
||||
/// HTTPS,无需关心明文流量问题。
|
||||
const String kProdBaseUrl = 'https://accounting.aqroid.cn/api';
|
||||
const String kLocalBaseUrl = 'http://localhost:12345/api';
|
||||
|
||||
/// 手动覆盖地址(优先级最高):
|
||||
/// `flutter run --dart-define=API_BASE_URL=http://<host>:12345/api`
|
||||
const String _override = String.fromEnvironment('API_BASE_URL');
|
||||
|
||||
String get baseUrl {
|
||||
if (_override.isNotEmpty) return _override;
|
||||
if (!kDebugMode) return kProdBaseUrl;
|
||||
return kLocalBaseUrl;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// 统一 API 异常(对齐 uniApp request.js 的错误语义)
|
||||
class ApiException implements Exception {
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
ApiException(this.code, this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
|
||||
/// 从任意抛出对象中提取 ApiException(供 UI/Provider 捕获使用)
|
||||
ApiException asApiException(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
return ApiException(-1, error.toString());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../storage/local_storage.dart';
|
||||
|
||||
/// 请求拦截器:注入 Bearer token(对齐 uniApp request.js 的 header 逻辑)
|
||||
class AuthInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final token = LocalStorage.instance.getToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/navigation/navigator_key.dart';
|
||||
import '../../pages/login/login_page.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
/// 认证失效处理桥接:网络层(无 Riverpod 上下文)通过此模块
|
||||
/// 调用 authProvider.logout 并跳转登录页。
|
||||
ProviderContainer? _container;
|
||||
|
||||
void configureAuthRedirect(ProviderContainer container) {
|
||||
_container = container;
|
||||
}
|
||||
|
||||
/// 主动退出登录(用户在「我的」页面点击)
|
||||
Future<void> logoutAndGoLogin() async {
|
||||
await _container?.read(authProvider.notifier).logout();
|
||||
_goLoginPage();
|
||||
}
|
||||
|
||||
/// 登录已失效(token 过期/无效):清本地登录态并跳登录页,给出提示
|
||||
Future<void> forceLogout({String reason = '登录已过期,请重新登录'}) async {
|
||||
await _container?.read(authProvider.notifier).logout();
|
||||
_goLoginPage();
|
||||
_showTip(reason);
|
||||
}
|
||||
|
||||
void _goLoginPage() {
|
||||
final nav = navigatorKey.currentState;
|
||||
if (nav == null) return;
|
||||
nav.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
void _showTip(String reason) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = navigatorKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(reason),
|
||||
backgroundColor: const Color(0xFFB71C1C),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'api_env.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
import 'error_interceptor.dart';
|
||||
|
||||
/// 全局 Dio 实例
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(),
|
||||
ErrorInterceptor(),
|
||||
]);
|
||||
return dio;
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'auth_redirect.dart';
|
||||
|
||||
/// 统一响应处理:业务 code 解包、登录失效跳转、错误映射为 ApiException。
|
||||
///
|
||||
/// dio 默认 validateStatus(<400) 下:HTTP 2xx → onResponse(解包业务 code),
|
||||
/// HTTP 4xx/5xx → onError(提取 message / 处理认证失效)。
|
||||
///
|
||||
/// ## 关于「登录失效」的判定(重要)
|
||||
/// 后端 `SecurityConfig` 未配置 authenticationEntryPoint,Spring Security 6
|
||||
/// 默认用 Http403ForbiddenEntryPoint,因此 **token 缺失/过期时返回的是 403 而非 401**。
|
||||
/// 所以这里两种都要处理:
|
||||
/// - 401:标准未授权(后端以后改对了也兼容)
|
||||
/// - 403:仅当请求的是需要鉴权的接口(非 permitAll 路径)时才算登录失效
|
||||
///
|
||||
/// permitAll 路径(见后端 SecurityConfig):`/auth/**`、`/gold-price/**`。
|
||||
/// 这些接口的 403 属于服务端错误(例如登录时数据库连不上),绝不能当成过期——
|
||||
/// 否则会把用户无故踢下线。
|
||||
class ErrorInterceptor extends Interceptor {
|
||||
bool _redirecting = false;
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
final data = response.data;
|
||||
// body.code == 200 或不存在 code 字段视为成功(与 uniApp request.js 一致)
|
||||
if (data is Map && data['code'] != null && data['code'] != 200) {
|
||||
final code = data['code'] is int ? data['code'] as int : -1;
|
||||
final message = data['message']?.toString() ?? '请求失败';
|
||||
if (code == 401) {
|
||||
_handleAuthFailure();
|
||||
handler.reject(_dioException(response.requestOptions, ApiException(401, message)));
|
||||
return;
|
||||
}
|
||||
handler.reject(_dioException(response.requestOptions, ApiException(code, message)));
|
||||
return;
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// 已包装为 ApiException 的直接透传
|
||||
if (err.error is ApiException) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final status = err.response?.statusCode;
|
||||
final path = err.response?.requestOptions.path ?? err.requestOptions.path;
|
||||
|
||||
// 401:标准未授权 → 视为登录失效
|
||||
if (status == 401) {
|
||||
_handleAuthFailure();
|
||||
handler.next(err.copyWith(error: ApiException(401, '登录已过期,请重新登录')));
|
||||
return;
|
||||
}
|
||||
|
||||
// 403:本后端未认证时的兜底状态码 → 受保护接口的 403 视为登录失效
|
||||
if (status == 403 && !_isPermitAllPath(path)) {
|
||||
_handleAuthFailure();
|
||||
handler.next(err.copyWith(error: ApiException(403, '登录已过期,请重新登录')));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTP 4xx:尽量提取后端返回的 message(含 permitAll 接口的真实服务端错误)
|
||||
final data = err.response?.data;
|
||||
if (data is Map && data['message'] != null) {
|
||||
handler.next(err.copyWith(error: ApiException(-1, data['message'].toString())));
|
||||
return;
|
||||
}
|
||||
// 403 且无 message:多为 Spring Security 兜底(空响应体),给出可读提示
|
||||
if (status == 403) {
|
||||
handler.next(err.copyWith(error: ApiException(403, '请求被拒绝(403),请查看后端日志')));
|
||||
return;
|
||||
}
|
||||
// 网络错误等统一映射
|
||||
final message = switch (err.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'连接超时,请检查网络',
|
||||
DioExceptionType.connectionError => '网络错误,请检查网络连接',
|
||||
_ => '请求失败(${err.response?.statusCode ?? '无响应'})',
|
||||
};
|
||||
handler.next(err.copyWith(error: ApiException(-1, message)));
|
||||
}
|
||||
|
||||
/// 后端 SecurityConfig 中 permitAll 的路径前缀,其 403 不代表登录失效
|
||||
bool _isPermitAllPath(String path) =>
|
||||
path.startsWith('/auth') || path.startsWith('/gold-price');
|
||||
|
||||
void _handleAuthFailure() {
|
||||
if (_redirecting) return;
|
||||
_redirecting = true;
|
||||
Future(() async {
|
||||
try {
|
||||
await forceLogout();
|
||||
} finally {
|
||||
Future.delayed(const Duration(seconds: 1), () => _redirecting = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DioException _dioException(RequestOptions options, ApiException error) =>
|
||||
DioException(requestOptions: options, error: error);
|
||||
}
|
||||
Reference in New Issue
Block a user