e66f79fdf6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
106 lines
3.7 KiB
Dart
106 lines
3.7 KiB
Dart
// api_client.dart — 统一鉴权 HTTP 客户端。
|
|
//
|
|
// 职责:为受 JWT 保护的端点(/v1/me、/v1/me/devices、/v1/plans、/v1/usage、
|
|
// /v1/redeem、/v1/ads/unlock 等)提供 GET/POST/DELETE 封装,自动:
|
|
// - 注入 Authorization: Bearer <access token>
|
|
// - 401 时调用 [refresh] 刷新令牌,成功则用新令牌重试一次
|
|
// - 错误统一包装为 AuthApiException(message_zh/en),UI 复用既有错误模型
|
|
import 'dart:convert';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'auth_api.dart' show AuthApiException;
|
|
|
|
/// 取当前 access token(可空表示未登录)。
|
|
typedef TokenGetter = String? Function();
|
|
|
|
/// 触发刷新;成功返回 true(调用方应已写入新令牌)。
|
|
typedef TokenRefresher = Future<bool> Function();
|
|
|
|
class ApiClient {
|
|
ApiClient({
|
|
required this.baseUrl,
|
|
required this.getToken,
|
|
required this.refresh,
|
|
http.Client? client,
|
|
}) : _client = client ?? http.Client();
|
|
|
|
final String baseUrl;
|
|
final TokenGetter getToken;
|
|
final TokenRefresher refresh;
|
|
final http.Client _client;
|
|
|
|
/// GET → 解析为 JSON 对象。
|
|
Future<Map<String, dynamic>> getJson(String path) async =>
|
|
_decodeObj(await _send(() => _req('GET', path)));
|
|
|
|
/// POST → 解析为 JSON 对象(空响应体返回空 map)。
|
|
Future<Map<String, dynamic>> postJson(String path, [Map<String, dynamic>? body]) async =>
|
|
_decodeObj(await _send(() => _req('POST', path, body)));
|
|
|
|
/// DELETE(无响应体)。
|
|
Future<void> delete(String path) async {
|
|
await _send(() => _req('DELETE', path));
|
|
}
|
|
|
|
// ── 内部 ──────────────────────────────────────────────────────────
|
|
|
|
/// 发送 + 401 自动刷新重试一次;非 2xx 抛 AuthApiException。
|
|
Future<http.Response> _send(Future<http.Response> Function() build) async {
|
|
http.Response resp;
|
|
try {
|
|
resp = await build();
|
|
if (resp.statusCode == 401 && await refresh()) {
|
|
resp = await build();
|
|
}
|
|
} on AuthApiException {
|
|
rethrow;
|
|
} on Exception catch (e) {
|
|
throw AuthApiException(
|
|
statusCode: -1,
|
|
messageZh: '网络请求失败,请检查连接后重试',
|
|
messageEn: 'Network error: $e',
|
|
);
|
|
}
|
|
if (resp.statusCode < 200 || resp.statusCode >= 300) _throwFromResponse(resp);
|
|
return resp;
|
|
}
|
|
|
|
Future<http.Response> _req(String method, String path, [Map<String, dynamic>? body]) {
|
|
final uri = Uri.parse('$baseUrl$path');
|
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
|
final t = getToken();
|
|
if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t';
|
|
final Future<http.Response> f;
|
|
switch (method) {
|
|
case 'GET':
|
|
f = _client.get(uri, headers: headers);
|
|
case 'DELETE':
|
|
f = _client.delete(uri, headers: headers);
|
|
default:
|
|
f = _client.post(uri, headers: headers, body: body == null ? null : jsonEncode(body));
|
|
}
|
|
return f.timeout(const Duration(seconds: 15));
|
|
}
|
|
|
|
Map<String, dynamic> _decodeObj(http.Response r) {
|
|
if (r.body.isEmpty) return <String, dynamic>{};
|
|
return jsonDecode(r.body) as Map<String, dynamic>;
|
|
}
|
|
|
|
Never _throwFromResponse(http.Response resp) {
|
|
String zh = '操作失败 (HTTP ${resp.statusCode})';
|
|
String en = 'Request failed (HTTP ${resp.statusCode})';
|
|
String? code;
|
|
try {
|
|
final b = jsonDecode(resp.body) as Map<String, dynamic>;
|
|
zh = b['message_zh'] as String? ?? zh;
|
|
en = b['message_en'] as String? ?? en;
|
|
code = b['code'] as String?;
|
|
} catch (_) {}
|
|
throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en, code: code);
|
|
}
|
|
|
|
void dispose() => _client.close();
|
|
}
|