Files
VibeCoding/webrtc_controller_flutter/lib/api/api_client.dart
tongtongstudio 6eb2c7321a feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
2026-08-01 15:13:12 +08:00

159 lines
5.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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内存保存掉线即失需重新登录
/// - refreshTokenflutter_secure_storageKeychain / 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;
}