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:
Zhou Weidong committed 2026-10-17 02:30:16 +08:00
1 parent 015677d1a8
commit a7ac3b7fc7
9 files changed
+602 -78

No files matched your search

@@ -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();
}
}
+136
View File
@@ -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();
}
}
+34
View File
@@ -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);
}
}