first commit

This commit is contained in:
2026-09-21 17:37:53 +08:00
commit 67d4dd5b7c
141 changed files with 8558 additions and 0 deletions
+31
View File
@@ -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;
}
+16
View File
@@ -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());
}
+15
View File
@@ -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);
}
}
+51
View File
@@ -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),
),
);
});
}
+20
View File
@@ -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;
});
+108
View File
@@ -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);
}