diff --git a/client/lib/models/device.dart b/client/lib/models/device.dart new file mode 100644 index 0000000..eb40372 --- /dev/null +++ b/client/lib/models/device.dart @@ -0,0 +1,28 @@ +// device.dart — 已登录设备(GET /v1/me/devices)。 +class Device { + const Device({ + required this.uuid, + required this.name, + required this.platform, + this.lastSeen, + }); + + final String uuid; + final String name; + + /// 'ios' | 'android' | 'windows' | 'macos'。 + final String platform; + + /// 最近活跃(UTC);从未上线为 null。 + final DateTime? lastSeen; + + factory Device.fromJson(Map m) { + final ls = m['last_seen'] as String?; + return Device( + uuid: m['uuid'] as String? ?? '', + name: m['name'] as String? ?? '', + platform: m['platform'] as String? ?? '', + lastSeen: (ls != null && ls.isNotEmpty) ? DateTime.tryParse(ls) : null, + ); + } +} diff --git a/client/lib/models/me.dart b/client/lib/models/me.dart new file mode 100644 index 0000000..9541347 --- /dev/null +++ b/client/lib/models/me.dart @@ -0,0 +1,60 @@ +// me.dart — 当前账户聚合视图(GET /v1/me)。 +// +// 一发覆盖账号/套餐/配额/今日+周流量,对齐后端契约 +// (server/internal/httpapi/account.go + web/usercenter http.ts)。 + +/// 账户档案。字段对齐后端 snake_case JSON。 +class Me { + const Me({ + required this.email, + required this.plan, + this.expiresAt, + this.devicesUsed = 0, + this.devicesMax = 1, + this.quotaTodayMin, + this.dataTodayGb = 0, + this.weeklyGb = const [], + this.totpEnabled = false, + }); + + final String email; + + /// 'free' | 'pro' | 'team'。 + final String plan; + + /// 订阅到期(UTC);免费版/无订阅为 null。 + final DateTime? expiresAt; + + final int devicesUsed; + final int devicesMax; + + /// 今日额度上限(分钟);null = 不限(pro/team)。 + final int? quotaTodayMin; + + /// 今日已用流量(GB)。 + final double dataTodayGb; + + /// 近 7 天每日流量(GB),旧→新。 + final List weeklyGb; + + final bool totpEnabled; + + bool get isFree => plan == 'free'; + + factory Me.fromJson(Map m) { + final exp = (m['expires_at'] ?? m['expire_at']) as String?; + return Me( + email: m['email'] as String? ?? '', + plan: m['plan'] as String? ?? 'free', + expiresAt: (exp != null && exp.isNotEmpty) ? DateTime.tryParse(exp) : null, + devicesUsed: (m['devices_used'] as num?)?.toInt() ?? 0, + devicesMax: (m['devices_max'] as num?)?.toInt() ?? 1, + quotaTodayMin: (m['quota_today_min'] as num?)?.toInt(), + dataTodayGb: (m['data_today_gb'] as num?)?.toDouble() ?? 0, + weeklyGb: ((m['weekly_gb'] as List?) ?? const []) + .map((e) => (e as num).toDouble()) + .toList(), + totpEnabled: m['totp_enabled'] as bool? ?? false, + ); + } +} diff --git a/client/lib/models/plan.dart b/client/lib/models/plan.dart new file mode 100644 index 0000000..958a872 --- /dev/null +++ b/client/lib/models/plan.dart @@ -0,0 +1,54 @@ +// plan.dart — 套餐定义(GET /v1/plans)。 +// +// 价格字段(price_cents/currency/period)由后端 P2 新增;旧响应缺失时降级为 0。 +import '../l10n/app_text.dart'; + +class Plan { + const Plan({ + required this.code, + required this.nameZh, + required this.nameEn, + this.dailyMinutes, + this.adGate = false, + this.priceCents = 0, + this.currency = 'CNY', + this.period = 'month', + }); + + /// 'free' | 'pro' | 'team'。 + final String code; + final String nameZh; + final String nameEn; + + /// 每日额度(分钟);null = 不限。 + final int? dailyMinutes; + final bool adGate; + + /// 价格(分)。0 = 免费。 + final int priceCents; + final String currency; + + /// 计费周期:'month' | 'year'。 + final String period; + + String localizedName(AppLang lang) => lang == AppLang.zh ? nameZh : nameEn; + + /// 价格展示:¥0 / ¥25 …(整数分转元,去尾零)。 + String priceLabel() { + final symbol = currency == 'CNY' ? '¥' : (currency == 'USD' ? '\$' : ''); + final yuan = priceCents / 100.0; + final s = yuan == yuan.roundToDouble() ? yuan.toStringAsFixed(0) : yuan.toStringAsFixed(2); + return '$symbol$s'; + } + + factory Plan.fromJson(Map m) => Plan( + code: m['code'] as String? ?? '', + nameZh: m['name_zh'] as String? ?? '', + nameEn: m['name_en'] as String? ?? '', + dailyMinutes: (m['daily_minutes'] as num?)?.toInt(), + adGate: m['ad_gate'] as bool? ?? false, + priceCents: (m['price_cents'] as num?)?.toInt() ?? 0, + currency: m['currency'] as String? ?? 'CNY', + period: m['period'] as String? ?? 'month', + ); +} diff --git a/client/lib/models/usage_point.dart b/client/lib/models/usage_point.dart new file mode 100644 index 0000000..6dd3189 --- /dev/null +++ b/client/lib/models/usage_point.dart @@ -0,0 +1,25 @@ +// usage_point.dart — 单日用量(GET /v1/usage?days=N 的一条)。 +class UsagePoint { + const UsagePoint({ + required this.date, + this.bytesUp = 0, + this.bytesDown = 0, + this.minutesUsed = 0, + }); + + /// YYYY-MM-DD(UTC)。 + final String date; + final int bytesUp; + final int bytesDown; + final int minutesUsed; + + int get bytesTotal => bytesUp + bytesDown; + double get gbTotal => bytesTotal / (1024 * 1024 * 1024); + + factory UsagePoint.fromJson(Map m) => UsagePoint( + date: m['date'] as String? ?? '', + bytesUp: (m['bytes_up'] as num?)?.toInt() ?? 0, + bytesDown: (m['bytes_down'] as num?)?.toInt() ?? 0, + minutesUsed: (m['minutes_used'] as num?)?.toInt() ?? 0, + ); +} diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart new file mode 100644 index 0000000..5175e4e --- /dev/null +++ b/client/lib/services/account_api.dart @@ -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 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() async => Me.fromJson(await _c.getJson('/v1/me')); + + /// GET /v1/plans — 套餐定义。 + Future> plans() async { + final body = await _c.getJson('/v1/plans'); + final raw = (body['plans'] as List?) ?? const []; + return raw.map((e) => Plan.fromJson(e as Map)).toList(); + } + + /// GET /v1/me/devices — 已登录设备列表。 + Future> devices() async { + final body = await _c.getJson('/v1/me/devices'); + final raw = (body['devices'] as List?) ?? const []; + return raw.map((e) => Device.fromJson(e as Map)).toList(); + } + + /// DELETE /v1/me/devices/{uuid} — 移除设备。 + Future removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid'); + + /// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。 + Future> usage({int days = 7}) async { + final body = await _c.getJson('/v1/usage?days=$days'); + final raw = (body['points'] as List?) ?? const []; + return raw.map((e) => UsagePoint.fromJson(e as Map)).toList(); + } + + /// POST /v1/redeem — 兑换码。 + Future redeem(String code) async => + RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code})); + + /// POST /v1/ads/unlock — 看广告解锁今日免费额度。 + Future adUnlock({required String deviceId, required String adToken}) => + _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken}); +} diff --git a/client/lib/services/api_client.dart b/client/lib/services/api_client.dart new file mode 100644 index 0000000..4114c96 --- /dev/null +++ b/client/lib/services/api_client.dart @@ -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 +// - 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 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> getJson(String path) async => + _decodeObj(await _send(() => _req('GET', path))); + + /// POST → 解析为 JSON 对象(空响应体返回空 map)。 + Future> postJson(String path, [Map? body]) async => + _decodeObj(await _send(() => _req('POST', path, body))); + + /// DELETE(无响应体)。 + Future delete(String path) async { + await _send(() => _req('DELETE', path)); + } + + // ── 内部 ────────────────────────────────────────────────────────── + + /// 发送 + 401 自动刷新重试一次;非 2xx 抛 AuthApiException。 + Future _send(Future 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 _req(String method, String path, [Map? body]) { + final uri = Uri.parse('$baseUrl$path'); + final headers = {'Content-Type': 'application/json'}; + final t = getToken(); + if (t != null && t.isNotEmpty) headers['Authorization'] = 'Bearer $t'; + final Future 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 _decodeObj(http.Response r) { + if (r.body.isEmpty) return {}; + return jsonDecode(r.body) as Map; + } + + 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; + 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(); +} diff --git a/client/lib/services/api_config.dart b/client/lib/services/api_config.dart new file mode 100644 index 0000000..0a20176 --- /dev/null +++ b/client/lib/services/api_config.dart @@ -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', +); diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart new file mode 100644 index 0000000..c6caa4e --- /dev/null +++ b/client/lib/state/account_providers.dart @@ -0,0 +1,25 @@ +// account_providers.dart — 账户域 Riverpod 装配。 +// +// 此处只做依赖装配:把 authProvider 的令牌/刷新接到统一 ApiClient, +// 再产出 AccountApi。具体数据 provider(meProvider/devicesProvider/usage…) +// 在后续阶段加入本文件。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/account_api.dart'; +import '../services/api_client.dart'; +import '../services/api_config.dart'; +import 'auth_provider.dart'; + +/// 统一鉴权 HTTP 客户端:令牌取自 authProvider,401 时触发 AuthNotifier.refresh。 +final apiClientProvider = Provider((ref) { + return ApiClient( + baseUrl: kApiBaseUrl, + getToken: () => ref.read(authProvider).accessToken, + refresh: () => ref.read(authProvider.notifier).refresh(), + ); +}); + +/// 账户域端点封装。 +final accountApiProvider = Provider( + (ref) => AccountApi(ref.watch(apiClientProvider)), +); diff --git a/client/lib/state/auth_provider.dart b/client/lib/state/auth_provider.dart index 4ddb1d0..141568f 100644 --- a/client/lib/state/auth_provider.dart +++ b/client/lib/state/auth_provider.dart @@ -1,6 +1,7 @@ // auth_provider.dart — 认证状态(JWT 令牌生命周期) import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/api_config.dart'; import '../services/auth_api.dart'; import '../services/token_store.dart'; @@ -23,11 +24,14 @@ class AuthState { // ── 状态机 ─────────────────────────────────────────────────────────── class AuthNotifier extends StateNotifier { - AuthNotifier(this._store) : super(const AuthState(isLoading: true)) { + AuthNotifier(this._store, {AuthApi? api}) + : _api = api ?? AuthApi(baseUrl: kApiBaseUrl), + super(const AuthState(isLoading: true)) { _loadFromStore(); } final TokenStore _store; + final AuthApi _api; Future _loadFromStore() async { try { @@ -48,6 +52,19 @@ class AuthNotifier extends StateNotifier { state = AuthState(accessToken: tokens.accessToken); } + /// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。 + /// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。 + Future refresh() async { + final rt = await _store.loadRefreshToken(); + if (rt == null || rt.isEmpty) return false; + try { + await saveTokens(await _api.refresh(rt)); + return true; + } catch (_) { + return false; + } + } + /// 退出登录:清除本地令牌。 Future logout() async { await _store.clear(); diff --git a/client/test/golden/desktop_pages_golden_test.dart b/client/test/golden/desktop_pages_golden_test.dart index 078e564..528f91c 100644 --- a/client/test/golden/desktop_pages_golden_test.dart +++ b/client/test/golden/desktop_pages_golden_test.dart @@ -26,6 +26,10 @@ class _NullTokenStore implements TokenStore { Future loadRefreshToken() async => null; @override Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; } Future _shoot(WidgetTester tester, NavView view, String name) async { diff --git a/client/test/unit/api_client_test.dart b/client/test/unit/api_client_test.dart new file mode 100644 index 0000000..7bbe770 --- /dev/null +++ b/client/test/unit/api_client_test.dart @@ -0,0 +1,62 @@ +// api_client_test.dart — ApiClient 鉴权 + 401 自动刷新重试。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:http/http.dart' as http; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; + +void main() { + group('ApiClient', () { + test('GET 附带 Bearer 并解析 JSON', () async { + late String seenAuth; + final c = ApiClient( + baseUrl: 'http://x', + getToken: () => 'tok1', + refresh: () async => false, + client: MockClient((req) async { + seenAuth = req.headers['Authorization'] ?? ''; + return http.Response('{"email":"a@b.c"}', 200); + }), + ); + final body = await c.getJson('/v1/me'); + expect(seenAuth, 'Bearer tok1'); + expect(body['email'], 'a@b.c'); + }); + + test('401 → refresh 成功 → 用新令牌重试一次', () async { + var token = 'old'; + var calls = 0; + final c = ApiClient( + baseUrl: 'http://x', + getToken: () => token, + refresh: () async { + token = 'new'; + return true; + }, + client: MockClient((req) async { + calls++; + if (req.headers['Authorization'] == 'Bearer old') { + return http.Response('{"message_zh":"expired"}', 401); + } + return http.Response('{"ok":true}', 200); + }), + ); + final body = await c.getJson('/v1/me'); + expect(calls, 2); // 首次 401 + 刷新后重试 + expect(body['ok'], true); + }); + + test('401 → refresh 失败 → 抛 AuthApiException(401)', () async { + final c = ApiClient( + baseUrl: 'http://x', + getToken: () => 'old', + refresh: () async => false, + client: MockClient((req) async => http.Response('{"message_zh":"expired"}', 401)), + ); + expect( + () => c.getJson('/v1/me'), + throwsA(isA().having((e) => e.statusCode, 'status', 401)), + ); + }); + }); +} diff --git a/client/test/unit/connection_controller_test.dart b/client/test/unit/connection_controller_test.dart index cd38ffe..a71152c 100644 --- a/client/test/unit/connection_controller_test.dart +++ b/client/test/unit/connection_controller_test.dart @@ -25,6 +25,10 @@ class _NullTokenStore implements TokenStore { Future loadRefreshToken() async => null; @override Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; } // ── 辅助 ────────────────────────────────────────────────────────── diff --git a/client/test/unit/nodes_provider_test.dart b/client/test/unit/nodes_provider_test.dart index 659234f..975379e 100644 --- a/client/test/unit/nodes_provider_test.dart +++ b/client/test/unit/nodes_provider_test.dart @@ -19,6 +19,10 @@ class _NullTokenStore implements TokenStore { Future loadRefreshToken() async => null; @override Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; } // ── 辅助:带 stub 覆盖的 ProviderContainer ──────────────────────