- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
159 lines
5.0 KiB
Dart
159 lines
5.0 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||
import 'package:http/http.dart' as http;
|
||
|
||
/// 服务端 HTTP 基址(与信令同源)。部署时通过 --dart-define=API_BASE= 注入。
|
||
const String kApiBase = String.fromEnvironment(
|
||
'API_BASE',
|
||
defaultValue: 'https://www.ttstd.com',
|
||
);
|
||
|
||
/// 账号体系与自助接口封装(对应服务端 /api/auth/* 与 /api/client/*)。
|
||
///
|
||
/// 令牌存储策略:
|
||
/// - accessToken:内存保存(掉线即失,需重新登录);
|
||
/// - refreshToken:flutter_secure_storage(Keychain / EncryptedSharedPreferences,一次性,ses_ 前缀)。
|
||
class ApiClient {
|
||
ApiClient({http.Client? client}) : _http = client ?? http.Client();
|
||
|
||
final http.Client _http;
|
||
static const _storage = FlutterSecureStorage();
|
||
|
||
static const _kAccess = 'ttstd.accessToken';
|
||
static const _kRefresh = 'ttstd.refreshToken';
|
||
static const _kUsername = 'ttstd.username';
|
||
|
||
// 刷新单飞:避免并发触发多次刷新。
|
||
Future<Map<String, dynamic>>? _refreshing;
|
||
|
||
String? _accessToken;
|
||
String? get accessToken => _accessToken;
|
||
|
||
/// 从安全存储恢复令牌(应用启动时调用)。
|
||
Future<void> restore() async {
|
||
_accessToken = await _storage.read(key: _kAccess);
|
||
}
|
||
|
||
bool get hasTokens {
|
||
// 同时读取内存 accessToken 与持久 refreshToken 判断。
|
||
return _accessToken != null;
|
||
}
|
||
|
||
Future<bool> hasRefreshToken() async =>
|
||
(await _storage.read(key: _kRefresh)) != null;
|
||
|
||
Future<void> saveTokens({
|
||
required String accessToken,
|
||
String? refreshToken,
|
||
String? username,
|
||
}) async {
|
||
_accessToken = accessToken;
|
||
await _storage.write(key: _kAccess, value: accessToken);
|
||
if (refreshToken != null) {
|
||
await _storage.write(key: _kRefresh, value: refreshToken);
|
||
}
|
||
if (username != null) {
|
||
await _storage.write(key: _kUsername, value: username);
|
||
}
|
||
}
|
||
|
||
Future<String?> getUsername() => _storage.read(key: _kUsername);
|
||
|
||
Future<void> clear() async {
|
||
_accessToken = null;
|
||
await _storage.delete(key: _kAccess);
|
||
await _storage.delete(key: _kRefresh);
|
||
await _storage.delete(key: _kUsername);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> login(String username, String password) async {
|
||
final data = await _post('/api/auth/login',
|
||
body: {'username': username, 'password': password});
|
||
await saveTokens(
|
||
accessToken: data['accessToken'] as String,
|
||
refreshToken: data['refreshToken'] as String?,
|
||
username: username,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> register(String username, String password) =>
|
||
_post('/api/auth/register',
|
||
body: {'username': username, 'password': password});
|
||
|
||
/// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。可能轮换 refreshToken。
|
||
Future<Map<String, dynamic>> refresh() async {
|
||
final rt = await _storage.read(key: _kRefresh);
|
||
if (rt == null) {
|
||
await clear();
|
||
throw ApiException('NO_REFRESH_TOKEN', 401);
|
||
}
|
||
_refreshing ??= _doRefresh(rt).whenComplete(() => _refreshing = null);
|
||
return _refreshing!;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> _doRefresh(String rt) async {
|
||
final data = await _post('/api/auth/refresh', body: {'refreshToken': rt});
|
||
await saveTokens(
|
||
accessToken: data['accessToken'] as String,
|
||
refreshToken: data['refreshToken'] as String?,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> verify() => _get('/api/client/verify');
|
||
|
||
Future<Map<String, dynamic>> bindings() => _get('/api/client/bindings');
|
||
|
||
/// 拉取 TURN 短期凭证(服务端开启时返回 iceServers);关闭时返回空 Map。
|
||
Future<Map<String, dynamic>?> turnCredentials() async {
|
||
try {
|
||
return await _get('/api/client/turn-credentials');
|
||
} on ApiException {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<Map<String, dynamic>> _post(String path, {required Map<String, dynamic> body}) async {
|
||
final res = await _http.post(
|
||
Uri.parse('$kApiBase$path'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode(body),
|
||
);
|
||
return _handle(res);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> _get(String path) async {
|
||
final res = await _http.get(
|
||
Uri.parse('$kApiBase$path'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
if (_accessToken != null) 'Authorization': 'Bearer $_accessToken',
|
||
},
|
||
);
|
||
return _handle(res);
|
||
}
|
||
|
||
Map<String, dynamic> _handle(http.Response res) {
|
||
final body = res.body.isNotEmpty ? jsonDecode(res.body) : <String, dynamic>{};
|
||
if (res.statusCode == 401) {
|
||
throw ApiException(body['code'] ?? 'UNAUTHORIZED', 401);
|
||
}
|
||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||
throw ApiException(body['error'] ?? 'HTTP ${res.statusCode}', res.statusCode);
|
||
}
|
||
return body as Map<String, dynamic>;
|
||
}
|
||
}
|
||
|
||
/// API 调用异常,携带服务端 code 与 HTTP 状态码。
|
||
class ApiException implements Exception {
|
||
final String code;
|
||
final int httpCode;
|
||
ApiException(this.code, this.httpCode);
|
||
|
||
@override
|
||
String toString() => code;
|
||
}
|