diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index b5d9466..2ebb9e0 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -115,12 +115,14 @@ class AuthApi { required String code, required String password, Map? device, + String? inviteCode, }) async { final resp = await _post('/v1/auth/register', { 'email': email, 'code': code, 'password': password, if (device != null) 'device': device, + if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode, }); if (resp.statusCode != 200 && resp.statusCode != 201) { _throwFromResponse(resp); diff --git a/client/lib/services/invite_api.dart b/client/lib/services/invite_api.dart new file mode 100644 index 0000000..af13de5 --- /dev/null +++ b/client/lib/services/invite_api.dart @@ -0,0 +1,35 @@ +// invite_api.dart — 邀请/奖励任务代理端点封装(JWT 经 ApiClient 自动注入)。 +import 'api_client.dart'; + +class InviteInfo { + const InviteInfo({ + required this.code, required this.link, required this.invited, + required this.converted, required this.earnedDays, + required this.tgEnabled, required this.tgJoined, required this.channel, + }); + final String code, link, channel; + final int invited, converted, earnedDays; + final bool tgEnabled, tgJoined; + + factory InviteInfo.fromJson(Map j) { + final tg = (j['telegram'] as Map?) ?? const {}; + return InviteInfo( + code: j['invite_code'] as String? ?? '', + link: j['invite_link'] as String? ?? '', + invited: (j['invited'] as num?)?.toInt() ?? 0, + converted: (j['converted'] as num?)?.toInt() ?? 0, + earnedDays: (j['earned_days'] as num?)?.toInt() ?? 0, + tgEnabled: tg['enabled'] as bool? ?? false, + tgJoined: tg['joined'] as bool? ?? false, + channel: tg['channel'] as String? ?? '', + ); + } +} + +class InviteApi { + InviteApi(this._c); + final ApiClient _c; + Future fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite')); + Future telegramStartLink() async => + (await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? ''; +} diff --git a/client/lib/state/invite_provider.dart b/client/lib/state/invite_provider.dart new file mode 100644 index 0000000..acd3acc --- /dev/null +++ b/client/lib/state/invite_provider.dart @@ -0,0 +1,25 @@ +// invite_provider.dart — 邀请信息状态装配:复用 apiClientProvider,未登录不打网络。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/invite_api.dart'; +import 'account_providers.dart'; +import 'auth_provider.dart'; + +final inviteApiProvider = Provider((ref) => InviteApi(ref.watch(apiClientProvider))); + +class InviteNotifier extends AsyncNotifier { + @override + Future build() async { + // 未登录返回 null(不打网络);已登录拉取。 + final token = ref.watch(authProvider).accessToken; + if (token == null || token.isEmpty) return null; + return ref.read(inviteApiProvider).fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch()); + } +} + +final inviteProvider = AsyncNotifierProvider(InviteNotifier.new); diff --git a/client/test/unit/invite_api_test.dart b/client/test/unit/invite_api_test.dart new file mode 100644 index 0000000..da1431a --- /dev/null +++ b/client/test/unit/invite_api_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/invite_api.dart'; + +ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); + +void main() { + test('fetch 解析邀请信息 + TG 任务态', () async { + final api = InviteApi(_c(MockClient((req) async { + expect(req.url.path, '/v1/invite'); + return http.Response('{"invite_code":"ABCD2345","invite_link":"https://x/i/ABCD2345",' + '"invited":3,"converted":1,"earned_days":16,' + '"telegram":{"enabled":true,"joined":false,"channel":"@pangolin_app"}}', 200); + }))); + final info = await api.fetch(); + expect(info.code, 'ABCD2345'); + expect(info.invited, 3); + expect(info.earnedDays, 16); + expect(info.tgEnabled, true); + expect(info.tgJoined, false); + }); +}