feat: A1 网络层与鉴权 + A2 账号登录对接
- 引入 dio,封装统一 ApiClient(BaseUrl/Token 注入/401 自动刷新/{code,message,data} 解包)
- TokenStorage 令牌持久化,AuthController 登录状态管理
- AuthRepository 封装 otp/login/logout 接口
- 登录页对接手机号/邮箱验证码登录(Google 登录待 OAuth client 配置)
This commit is contained in:
1 parent
015677d1a8
commit
a7ac3b7fc7
9 files changed
+602
-78
No files matched your search
@@ -0,0 +1,136 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/api_config.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// 统一业务异常:code 为后端业务码,message 可直接展示。
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code): $message';
|
||||
}
|
||||
|
||||
/// 统一 HTTP 客户端:BaseUrl、Token 注入、401 自动刷新、{code,message,data} 解包。
|
||||
class ApiClient {
|
||||
ApiClient({Dio? dio, TokenStorage? tokenStorage})
|
||||
: _dio = dio ?? Dio(),
|
||||
_tokenStorage = tokenStorage ?? TokenStorage() {
|
||||
_dio.options
|
||||
..baseUrl = ApiConfig.baseUrl
|
||||
..connectTimeout = const Duration(
|
||||
seconds: ApiConfig.connectTimeoutSeconds,
|
||||
)
|
||||
..receiveTimeout = const Duration(
|
||||
seconds: ApiConfig.receiveTimeoutSeconds,
|
||||
);
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(onRequest: _onRequest, onError: _onError),
|
||||
);
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
final TokenStorage _tokenStorage;
|
||||
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final access = await _tokenStorage.readAccessToken();
|
||||
if (access != null && access.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $access';
|
||||
}
|
||||
// 设备标识:第一阶段固定占位,A2 接入真实 deviceId。
|
||||
options.headers['X-Device-Id'] = 'device-0001';
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
Future<void> _onError(
|
||||
DioException error,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final status = error.response?.statusCode;
|
||||
final path = error.requestOptions.path;
|
||||
final isAuthEndpoint =
|
||||
path.endsWith('/auth/refresh') || path.endsWith('/auth/login');
|
||||
if (status == 401 && !isAuthEndpoint && !_refreshing) {
|
||||
final refreshed = await _refresh();
|
||||
if (refreshed) {
|
||||
handler.resolve(await _retry(error.requestOptions));
|
||||
return;
|
||||
}
|
||||
}
|
||||
handler.next(error);
|
||||
}
|
||||
|
||||
Future<bool> _refresh() async {
|
||||
_refreshing = true;
|
||||
try {
|
||||
final refresh = await _tokenStorage.readRefreshToken();
|
||||
if (refresh == null || refresh.isEmpty) return false;
|
||||
final response = await _dio.post<dynamic>(
|
||||
'/app/auth/refresh',
|
||||
data: {'refreshToken': refresh},
|
||||
);
|
||||
final body = response.data;
|
||||
if (body is Map && body['code'] == 0) {
|
||||
final data = body['data'] as Map;
|
||||
await _tokenStorage.saveTokens(
|
||||
access: data['accessToken'] as String,
|
||||
refresh: data['refreshToken'] as String,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retry(RequestOptions options) {
|
||||
return _dio.fetch<dynamic>(options);
|
||||
}
|
||||
|
||||
Future<dynamic> get(String path, {Map<String, dynamic>? query}) {
|
||||
return _request(() => _dio.get<dynamic>(path, queryParameters: query));
|
||||
}
|
||||
|
||||
Future<dynamic> post(String path, {Object? data}) {
|
||||
return _request(() => _dio.post<dynamic>(path, data: data));
|
||||
}
|
||||
|
||||
Future<dynamic> put(String path, {Object? data}) {
|
||||
return _request(() => _dio.put<dynamic>(path, data: data));
|
||||
}
|
||||
|
||||
Future<dynamic> delete(String path, {Object? data}) {
|
||||
return _request(() => _dio.delete<dynamic>(path, data: data));
|
||||
}
|
||||
|
||||
Future<dynamic> _request(Future<Response<dynamic>> Function() send) async {
|
||||
try {
|
||||
final response = await send();
|
||||
final body = response.data;
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final code = body['code'];
|
||||
if (code == 0) return body['data'];
|
||||
throw ApiException(code as int, (body['message'] as String?) ?? '');
|
||||
}
|
||||
return body;
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(
|
||||
e.response?.statusCode ?? -1,
|
||||
e.message ?? 'network error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../repositories/auth_repository.dart';
|
||||
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||
|
||||
/// 登录状态与账号操作。
|
||||
class AuthController extends ChangeNotifier {
|
||||
AuthController(this._repository);
|
||||
|
||||
final AuthRepository _repository;
|
||||
|
||||
AuthStatus _status = AuthStatus.unknown;
|
||||
AuthStatus get status => _status;
|
||||
|
||||
Future<void> sendOtp({
|
||||
required String scene,
|
||||
required String targetType,
|
||||
required String target,
|
||||
}) {
|
||||
return _repository.sendOtp(
|
||||
scene: scene,
|
||||
targetType: targetType,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loginWithCode({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String code,
|
||||
}) async {
|
||||
final session = await _repository.loginWithCode(
|
||||
targetType: targetType,
|
||||
target: target,
|
||||
code: code,
|
||||
);
|
||||
await _repository.saveSession(session);
|
||||
_status = AuthStatus.authenticated;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loginWithPassword({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String password,
|
||||
}) async {
|
||||
final session = await _repository.loginWithPassword(
|
||||
targetType: targetType,
|
||||
target: target,
|
||||
password: password,
|
||||
);
|
||||
await _repository.saveSession(session);
|
||||
_status = AuthStatus.authenticated;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _repository.logout();
|
||||
_status = AuthStatus.unauthenticated;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 令牌本地存储。第一阶段用 shared_preferences,后续可换 secure storage。
|
||||
class TokenStorage {
|
||||
static const _accessKey = 'auth_access_token';
|
||||
static const _refreshKey = 'auth_refresh_token';
|
||||
|
||||
Future<void> saveTokens({
|
||||
required String access,
|
||||
required String refresh,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_accessKey, access);
|
||||
await prefs.setString(_refreshKey, refresh);
|
||||
}
|
||||
|
||||
Future<String?> readAccessToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_accessKey);
|
||||
}
|
||||
|
||||
Future<String?> readRefreshToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_refreshKey);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_accessKey);
|
||||
await prefs.remove(_refreshKey);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user