feat(client): P1 真实数据接入地基(#6 6A)
- api_config.dart:API 基址单源(消除各处重复 _kApiUrl) - api_client.dart:统一鉴权 HTTP 客户端,401→AuthNotifier.refresh→重试一次, 错误统一 AuthApiException;+ 单测覆盖 401 刷新重试 - 模型 me/device/plan/usage_point(对齐后端 snake_case 契约) - account_api.dart:/v1/me、/v1/plans、/v1/me/devices(列表+删)、/v1/usage、 /v1/redeem、/v1/ads/unlock 封装 - auth_provider:注入 AuthApi + refresh();account_providers 装配 ApiClient/AccountApi - 顺带修复 onboarding 提交遗留的测试 stub(_NullTokenStore 缺 isOnboarded/markOnboarded) flutter analyze 0 error;114 tests passed。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// account_api.dart — 账户域端点封装(受 JWT 保护,走 ApiClient)。
|
||||
//
|
||||
// 覆盖:/v1/me、/v1/plans、/v1/me/devices(列表+删除)、/v1/usage、
|
||||
// /v1/redeem、/v1/ads/unlock。错误统一 AuthApiException(由 ApiClient 抛)。
|
||||
import '../models/device.dart';
|
||||
import '../models/me.dart';
|
||||
import '../models/plan.dart';
|
||||
import '../models/usage_point.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 兑换结果(POST /v1/redeem)。
|
||||
class RedeemResult {
|
||||
const RedeemResult({
|
||||
required this.plan,
|
||||
required this.durationDays,
|
||||
this.idempotent = false,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
final String plan;
|
||||
final int durationDays;
|
||||
final bool idempotent;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
factory RedeemResult.fromJson(Map<String, dynamic> m) {
|
||||
final exp = m['expires_at'] as String?;
|
||||
return RedeemResult(
|
||||
plan: m['plan'] as String? ?? '',
|
||||
durationDays: (m['duration_days'] as num?)?.toInt() ?? 0,
|
||||
idempotent: m['idempotent'] as bool? ?? false,
|
||||
expiresAt: (exp != null && exp.isNotEmpty) ? DateTime.tryParse(exp) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountApi {
|
||||
AccountApi(this._c);
|
||||
|
||||
final ApiClient _c;
|
||||
|
||||
/// GET /v1/me — 账户聚合视图。
|
||||
Future<Me> me() async => Me.fromJson(await _c.getJson('/v1/me'));
|
||||
|
||||
/// GET /v1/plans — 套餐定义。
|
||||
Future<List<Plan>> plans() async {
|
||||
final body = await _c.getJson('/v1/plans');
|
||||
final raw = (body['plans'] as List<dynamic>?) ?? const [];
|
||||
return raw.map((e) => Plan.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// GET /v1/me/devices — 已登录设备列表。
|
||||
Future<List<Device>> devices() async {
|
||||
final body = await _c.getJson('/v1/me/devices');
|
||||
final raw = (body['devices'] as List<dynamic>?) ?? const [];
|
||||
return raw.map((e) => Device.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// DELETE /v1/me/devices/{uuid} — 移除设备。
|
||||
Future<void> removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid');
|
||||
|
||||
/// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。
|
||||
Future<List<UsagePoint>> usage({int days = 7}) async {
|
||||
final body = await _c.getJson('/v1/usage?days=$days');
|
||||
final raw = (body['points'] as List<dynamic>?) ?? const [];
|
||||
return raw.map((e) => UsagePoint.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// POST /v1/redeem — 兑换码。
|
||||
Future<RedeemResult> redeem(String code) async =>
|
||||
RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code}));
|
||||
|
||||
/// POST /v1/ads/unlock — 看广告解锁今日免费额度。
|
||||
Future<void> adUnlock({required String deviceId, required String adToken}) =>
|
||||
_c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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})';
|
||||
try {
|
||||
final b = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
zh = b['message_zh'] as String? ?? zh;
|
||||
en = b['message_en'] as String? ?? en;
|
||||
} catch (_) {}
|
||||
throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en);
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// api_config.dart — 控制面 API 基址单源。
|
||||
//
|
||||
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
|
||||
// 由 --dart-define=PANGOLIN_API_URL 注入(默认本地 8080)。
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'PANGOLIN_API_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
);
|
||||
Reference in New Issue
Block a user