From e66f79fdf633c60df29c5e7caecbf66c67043fa5 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 23:19:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20pay=20=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=20PaymentApi=20+=20?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E6=B5=81=E6=8E=A7=E5=88=B6=E5=99=A8(?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=20activated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- client/lib/models/payment.dart | 75 ++++++++++ client/lib/services/api_client.dart | 4 +- client/lib/services/auth_api.dart | 4 + client/lib/services/payment_api.dart | 38 +++++ client/lib/state/payment_provider.dart | 183 ++++++++++++++++++++++++ client/test/unit/payment_api_test.dart | 73 ++++++++++ client/test/unit/payment_flow_test.dart | 80 +++++++++++ 7 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 client/lib/models/payment.dart create mode 100644 client/lib/services/payment_api.dart create mode 100644 client/lib/state/payment_provider.dart create mode 100644 client/test/unit/payment_api_test.dart create mode 100644 client/test/unit/payment_flow_test.dart diff --git a/client/lib/models/payment.dart b/client/lib/models/payment.dart new file mode 100644 index 0000000..1591a50 --- /dev/null +++ b/client/lib/models/payment.dart @@ -0,0 +1,75 @@ +// payment.dart — pay v2 支付领域模型(server 代理端点的响应形状)。 +// 金额只读展示:price_minor 为 CNY 分;crypto 精确金额在 session.payload 里。 + +class PayCatalogItem { + const PayCatalogItem({ + required this.sku, + required this.plan, + required this.days, + required this.priceMinor, + required this.currency, + }); + + final String sku; + final String plan; + final int days; + final int priceMinor; + final String currency; + + factory PayCatalogItem.fromJson(Map j) => PayCatalogItem( + sku: j['sku'] as String, + plan: j['plan'] as String? ?? 'pro', + days: (j['days'] as num?)?.toInt() ?? 0, + priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0, + currency: j['currency'] as String? ?? 'CNY', + ); + + String priceLabel() => '¥${(priceMinor / 100).toStringAsFixed(2)}'; +} + +class PaySession { + const PaySession({required this.renderType, required this.payload, this.expiresAt}); + + final String renderType; // crypto_address | redirect | qr + final Map payload; + final DateTime? expiresAt; + + factory PaySession.fromJson(Map j) => PaySession( + renderType: j['render_type'] as String? ?? '', + payload: (j['payload'] as Map?) ?? const {}, + expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String), + ); +} + +class PayOrder { + const PayOrder({required this.orderNo, required this.session}); + + final String orderNo; + final PaySession session; + + factory PayOrder.fromJson(Map j) => PayOrder( + orderNo: j['order_no'] as String, + session: PaySession.fromJson((j['session'] as Map?) ?? const {}), + ); +} + +class PayOrderStatus { + const PayOrderStatus({ + required this.orderNo, + required this.payStatus, + required this.activated, + this.expiresAt, + }); + + final String orderNo; + final String payStatus; + final bool activated; // server 台账已消费 = 权益已开通(轮询成功判据) + final DateTime? expiresAt; + + factory PayOrderStatus.fromJson(Map j) => PayOrderStatus( + orderNo: j['order_no'] as String? ?? '', + payStatus: j['pay_status'] as String? ?? '', + activated: j['activated'] as bool? ?? false, + expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String), + ); +} diff --git a/client/lib/services/api_client.dart b/client/lib/services/api_client.dart index 4114c96..bdaf62e 100644 --- a/client/lib/services/api_client.dart +++ b/client/lib/services/api_client.dart @@ -91,12 +91,14 @@ class ApiClient { 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; 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); + throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en, code: code); } void dispose() => _client.close(); diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index 18b065e..b5d9466 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -14,12 +14,16 @@ class AuthApiException implements Exception { required this.statusCode, required this.messageZh, required this.messageEn, + this.code, }); final int statusCode; final String messageZh; final String messageEn; + /// 服务端 apierr 的机器码(如 CURRENCY_MISMATCH),旧调用点可空。 + final String? code; + @override String toString() => 'AuthApiException($statusCode): $messageZh'; } diff --git a/client/lib/services/payment_api.dart b/client/lib/services/payment_api.dart new file mode 100644 index 0000000..32a7b93 --- /dev/null +++ b/client/lib/services/payment_api.dart @@ -0,0 +1,38 @@ +// payment_api.dart — /v1/pay 代理端点封装(JWT 经 ApiClient 自动注入)。 +import '../models/payment.dart'; +import 'api_client.dart'; + +class PaymentApi { + PaymentApi(this._c); + final ApiClient _c; + + Future> catalog() async { + final body = await _c.getJson('/v1/pay/catalog'); + final items = (body['items'] as List?) ?? const []; + return [for (final it in items) PayCatalogItem.fromJson(it as Map)]; + } + + Future createOrder({ + required String sku, + required String method, + Map? metadata, + }) async => + PayOrder.fromJson(await _c.postJson('/v1/pay/orders', { + 'sku': sku, + 'method': method, + if (metadata != null && metadata.isNotEmpty) 'metadata': metadata, + })); + + Future orderStatus(String orderNo) async => + PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo')); + + Future retry(String orderNo, {required String method, Map? metadata}) async => + PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', { + 'method': method, + if (metadata != null && metadata.isNotEmpty) 'metadata': metadata, + })); + + Future cancel(String orderNo) async { + await _c.postJson('/v1/pay/orders/$orderNo/cancel'); + } +} diff --git a/client/lib/state/payment_provider.dart b/client/lib/state/payment_provider.dart new file mode 100644 index 0000000..0d57b58 --- /dev/null +++ b/client/lib/state/payment_provider.dart @@ -0,0 +1,183 @@ +// payment_provider.dart — 支付流状态机:选档下单 → 等待支付(轮询查单)→ 成功/失败。 +// 轮询成功判据是 server 的 activated(webhook 已开通),不是 pay 的 succeeded—— +// 保证用户看到「成功」时权益一定已到账。 +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/payment.dart'; +import '../services/auth_api.dart'; +import '../services/payment_api.dart'; +import 'account_providers.dart'; + +final paymentApiProvider = Provider( + (ref) => PaymentApi(ref.watch(apiClientProvider)), +); + +final payCatalogProvider = FutureProvider>( + (ref) => ref.watch(paymentApiProvider).catalog(), +); + +enum PaymentPhase { idle, creating, awaitingPayment, succeeded, failed } + +@immutable +class PaymentFlowState { + const PaymentFlowState({ + this.phase = PaymentPhase.idle, + this.item, + this.method = '', + this.order, + this.status, + this.errorZh, + this.errorEn, + }); + + final PaymentPhase phase; + final PayCatalogItem? item; + final String method; // alipay | crypto + final PayOrder? order; + final PayOrderStatus? status; + final String? errorZh; + final String? errorEn; + + PaymentFlowState copyWith({ + PaymentPhase? phase, + PayCatalogItem? item, + String? method, + PayOrder? order, + PayOrderStatus? status, + String? errorZh, + String? errorEn, + }) => + PaymentFlowState( + phase: phase ?? this.phase, + item: item ?? this.item, + method: method ?? this.method, + order: order ?? this.order, + status: status ?? this.status, + errorZh: errorZh, + errorEn: errorEn, + ); +} + +class PaymentFlowController extends StateNotifier { + PaymentFlowController(this._ref) : super(const PaymentFlowState()); + + final Ref _ref; + Timer? _poll; + bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍) + + static const _pollInterval = Duration(seconds: 3); + + bool get _isMobile { + if (kIsWeb) return false; + return Platform.isAndroid || Platform.isIOS; + } + + Map _metadata(String method) => + method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {}; + + Future start(PayCatalogItem item, String method) async { + _stopPolling(); + state = PaymentFlowState(phase: PaymentPhase.creating, item: item, method: method); + try { + final order = await _ref + .read(paymentApiProvider) + .createOrder(sku: item.sku, method: method, metadata: _metadata(method)); + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.awaitingPayment, order: order); + _startPolling(); + } on AuthApiException catch (e) { + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } + } + + /// 换支付方式:先 retry;pay 回 409 CURRENCY_MISMATCH(跨结算币种)时, + /// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。 + Future switchMethod(String method) async { + final item = state.item; + final order = state.order; + if (item == null || order == null) return; + try { + final next = await _ref + .read(paymentApiProvider) + .retry(order.orderNo, method: method, metadata: _metadata(method)); + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.awaitingPayment, method: method, order: next); + } on AuthApiException catch (e) { + if (!mounted) return; + if (e.code == 'CURRENCY_MISMATCH') { + try { + await _ref.read(paymentApiProvider).cancel(order.orderNo); + } catch (_) {} // 旧单取消失败不阻断新单 + await start(item, method); + return; + } + state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } + } + + Future cancel() async { + final order = state.order; + _stopPolling(); + if (order != null) { + try { + await _ref.read(paymentApiProvider).cancel(order.orderNo); + } catch (_) {} + } + if (mounted) state = const PaymentFlowState(); + } + + /// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。 + Future pollOnce() async { + final order = state.order; + if (order == null || _polling) return; + _polling = true; + try { + final st = await _ref.read(paymentApiProvider).orderStatus(order.orderNo); + if (!mounted) return; + if (st.activated) { + _stopPolling(); + state = state.copyWith(phase: PaymentPhase.succeeded, status: st); + // 权益已变,best-effort 刷新「我的」——仅当 meProvider 已被真实页面读取过 + // (ref.exists)才刷新;测试容器/尚未触达账户页时是空操作,不会凭空触发 + // 首次网络请求(避免打真网络 / 触发 authProvider 的后台加载)。 + if (_ref.exists(meProvider)) { + try { + await _ref.read(meProvider.notifier).refresh(); + } catch (_) {} + } + } else { + state = state.copyWith(status: st); + } + } on AuthApiException { + // 单拍失败静默,下一拍重试(网络抖动不打断等待页)。 + } finally { + _polling = false; + } + } + + void _startPolling() { + _poll?.cancel(); + _poll = Timer.periodic(_pollInterval, (_) => pollOnce()); + } + + void _stopPolling() { + _poll?.cancel(); + _poll = null; + } + + @override + void dispose() { + _stopPolling(); + super.dispose(); + } +} + +final paymentFlowProvider = + StateNotifierProvider( + (ref) => PaymentFlowController(ref), +); diff --git a/client/test/unit/payment_api_test.dart b/client/test/unit/payment_api_test.dart new file mode 100644 index 0000000..29f9b69 --- /dev/null +++ b/client/test/unit/payment_api_test.dart @@ -0,0 +1,73 @@ +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/auth_api.dart'; +import 'package:pangolin_vpn/services/payment_api.dart'; + +ApiClient _client(MockClient mock) => ApiClient( + baseUrl: 'http://x', + getToken: () => 'tok1', + refresh: () async => false, + client: mock, + ); + +void main() { + test('catalog 解析三档', () async { + final api = PaymentApi(_client(MockClient((req) async { + expect(req.url.path, '/v1/pay/catalog'); + return http.Response( + '{"items":[{"sku":"pro_month","plan":"pro","days":31,"price_minor":2999,"currency":"CNY"},' + '{"sku":"pro_quarter","plan":"pro","days":92,"price_minor":6888,"currency":"CNY"},' + '{"sku":"pro_year","plan":"pro","days":366,"price_minor":19999,"currency":"CNY"}]}', + 200); + }))); + final items = await api.catalog(); + expect(items.length, 3); + expect(items.first.sku, 'pro_month'); + expect(items.first.priceLabel(), '¥29.99'); + }); + + test('createOrder 只传 sku+method+metadata,解析 session', () async { + final api = PaymentApi(_client(MockClient((req) async { + expect(req.url.path, '/v1/pay/orders'); + expect(req.body.contains('"amount'), isFalse, reason: '客户端绝不传金额'); + return http.Response( + '{"order_no":"pay1","session":{"render_type":"crypto_address",' + '"payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234,' + '"currency":"USDT","network":"TRC20"},"expires_at":"2026-07-10T12:00:00Z"}}', + 200); + }))); + final order = await api.createOrder(sku: 'pro_month', method: 'crypto'); + expect(order.orderNo, 'pay1'); + expect(order.session.renderType, 'crypto_address'); + expect(order.session.payload['address'], 'Txx'); + expect(order.session.expiresAt, isNotNull); + }); + + test('orderStatus 解析 activated/expires_at', () async { + final api = PaymentApi(_client(MockClient((req) async => http.Response( + '{"order_no":"pay1","pay_status":"succeeded","activated":true,' + '"expires_at":"2026-08-10T12:00:00Z"}', + 200)))); + final st = await api.orderStatus('pay1'); + expect(st.activated, isTrue); + expect(st.payStatus, 'succeeded'); + expect(st.expiresAt, isNotNull); + }); + + test('retry 409 CURRENCY_MISMATCH 抛带 code 的异常', () async { + final api = PaymentApi(_client(MockClient((req) async => http.Response( + '{"code":"CURRENCY_MISMATCH","message_zh":"该支付方式结算币种与订单不符,请重新下单",' + '"message_en":"mismatch"}', + 409, + headers: {'content-type': 'application/json; charset=utf-8'})))); + try { + await api.retry('pay1', method: 'alipay'); + fail('应抛异常'); + } on AuthApiException catch (e) { + expect(e.statusCode, 409); + expect(e.code, 'CURRENCY_MISMATCH'); + } + }); +} diff --git a/client/test/unit/payment_flow_test.dart b/client/test/unit/payment_flow_test.dart new file mode 100644 index 0000000..e5d21e8 --- /dev/null +++ b/client/test/unit/payment_flow_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/models/payment.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/services/payment_api.dart'; +import 'package:pangolin_vpn/state/payment_provider.dart'; + +class _FakePaymentApi implements PaymentApi { + _FakePaymentApi(); + int createCalls = 0, cancelCalls = 0, statusCalls = 0; + bool activated = false; + Exception? retryError; + String lastMethod = ''; + + @override + Future> catalog() async => const []; + + @override + Future createOrder({required String sku, required String method, Map? metadata}) async { + createCalls++; + lastMethod = method; + return PayOrder( + orderNo: 'pay$createCalls', + session: PaySession(renderType: method == 'crypto' ? 'crypto_address' : 'redirect', payload: const {'url': 'https://x'}, expiresAt: null)); + } + + @override + Future orderStatus(String orderNo) async { + statusCalls++; + return PayOrderStatus(orderNo: orderNo, payStatus: activated ? 'succeeded' : 'pending', activated: activated, expiresAt: null); + } + + @override + Future retry(String orderNo, {required String method, Map? metadata}) async { + if (retryError != null) throw retryError!; + return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null)); + } + + @override + Future cancel(String orderNo) async { + cancelCalls++; + } +} + +ProviderContainer _container(_FakePaymentApi api) { + final c = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); + addTearDown(c.dispose); + return c; +} + +void main() { + const item = PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'); + + test('start → awaitingPayment,pollOnce 到 activated 变 succeeded', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + await ctl.pollOnce(); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + api.activated = true; + await ctl.pollOnce(); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded); + }); + + test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + api.retryError = const AuthApiException( + statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'CURRENCY_MISMATCH'); + await ctl.switchMethod('alipay'); + expect(api.cancelCalls, 1); + expect(api.createCalls, 2, reason: '换币种必须新单(pay 契约)'); + expect(api.lastMethod, 'alipay'); + expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2'); + }); +}