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,15 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 接口地址与超时配置。
|
||||
class ApiConfig {
|
||||
ApiConfig._();
|
||||
|
||||
/// 客户端 app-api 地址,可用 --dart-define=API_BASE_URL=... 覆盖。
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://127.0.0.1:8081',
|
||||
);
|
||||
|
||||
static const int connectTimeoutSeconds = 10;
|
||||
static const int receiveTimeoutSeconds = 15;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// 登录/刷新成功后返回的会话令牌。
|
||||
class AuthSession {
|
||||
AuthSession({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.newUser,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final bool newUser;
|
||||
|
||||
factory AuthSession.fromJson(Map<String, dynamic> json) {
|
||||
return AuthSession(
|
||||
accessToken: json['accessToken'] as String,
|
||||
refreshToken: json['refreshToken'] as String,
|
||||
expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 7200,
|
||||
newUser: json['newUser'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号与鉴权接口封装。
|
||||
class AuthRepository {
|
||||
AuthRepository(this._client, this._tokenStorage);
|
||||
|
||||
final ApiClient _client;
|
||||
final TokenStorage _tokenStorage;
|
||||
|
||||
/// 发送验证码。
|
||||
Future<void> sendOtp({
|
||||
required String scene,
|
||||
required String targetType,
|
||||
required String target,
|
||||
}) async {
|
||||
await _client.post('/app/auth/otp', data: {
|
||||
'scene': scene,
|
||||
'targetType': targetType,
|
||||
'target': target,
|
||||
});
|
||||
}
|
||||
|
||||
/// 验证码登录。
|
||||
Future<AuthSession> loginWithCode({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String code,
|
||||
}) async {
|
||||
final data = await _client.post('/app/auth/login', data: {
|
||||
'targetType': targetType,
|
||||
'target': target,
|
||||
'code': code,
|
||||
});
|
||||
return AuthSession.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 密码登录。
|
||||
Future<AuthSession> loginWithPassword({
|
||||
required String targetType,
|
||||
required String target,
|
||||
required String password,
|
||||
}) async {
|
||||
final data = await _client.post('/app/auth/login', data: {
|
||||
'targetType': targetType,
|
||||
'target': target,
|
||||
'password': password,
|
||||
});
|
||||
return AuthSession.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 保存会话令牌。
|
||||
Future<void> saveSession(AuthSession session) async {
|
||||
await _tokenStorage.saveTokens(
|
||||
access: session.accessToken,
|
||||
refresh: session.refreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登出并清理本地令牌。
|
||||
Future<void> logout() async {
|
||||
await _client.post('/app/auth/logout');
|
||||
await _tokenStorage.clear();
|
||||
}
|
||||
}
|
||||
@@ -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