feat(client): pay 支付领域模型 + PaymentApi + 支付流控制器(轮询 activated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 23:19:57 +08:00
parent 16b8823f1f
commit e66f79fdf6
7 changed files with 456 additions and 1 deletions
+75
View File
@@ -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<String, dynamic> 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<String, dynamic> payload;
final DateTime? expiresAt;
factory PaySession.fromJson(Map<String, dynamic> j) => PaySession(
renderType: j['render_type'] as String? ?? '',
payload: (j['payload'] as Map<String, dynamic>?) ?? 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<String, dynamic> j) => PayOrder(
orderNo: j['order_no'] as String,
session: PaySession.fromJson((j['session'] as Map<String, dynamic>?) ?? 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<String, dynamic> 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),
);
}
+3 -1
View File
@@ -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<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);
throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en, code: code);
}
void dispose() => _client.close();
+4
View File
@@ -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';
}
+38
View File
@@ -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<List<PayCatalogItem>> catalog() async {
final body = await _c.getJson('/v1/pay/catalog');
final items = (body['items'] as List<dynamic>?) ?? const [];
return [for (final it in items) PayCatalogItem.fromJson(it as Map<String, dynamic>)];
}
Future<PayOrder> createOrder({
required String sku,
required String method,
Map<String, String>? metadata,
}) async =>
PayOrder.fromJson(await _c.postJson('/v1/pay/orders', {
'sku': sku,
'method': method,
if (metadata != null && metadata.isNotEmpty) 'metadata': metadata,
}));
Future<PayOrderStatus> orderStatus(String orderNo) async =>
PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo'));
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async =>
PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', {
'method': method,
if (metadata != null && metadata.isNotEmpty) 'metadata': metadata,
}));
Future<void> cancel(String orderNo) async {
await _c.postJson('/v1/pay/orders/$orderNo/cancel');
}
}
+183
View File
@@ -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<PaymentApi>(
(ref) => PaymentApi(ref.watch(apiClientProvider)),
);
final payCatalogProvider = FutureProvider<List<PayCatalogItem>>(
(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<PaymentFlowState> {
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<String, String> _metadata(String method) =>
method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {};
Future<void> 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<void> 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<void> 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<void> 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<PaymentFlowController, PaymentFlowState>(
(ref) => PaymentFlowController(ref),
);
+73
View File
@@ -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');
}
});
}
+80
View File
@@ -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<List<PayCatalogItem>> catalog() async => const [];
@override
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? 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<PayOrderStatus> orderStatus(String orderNo) async {
statusCalls++;
return PayOrderStatus(orderNo: orderNo, payStatus: activated ? 'succeeded' : 'pending', activated: activated, expiresAt: null);
}
@override
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async {
if (retryError != null) throw retryError!;
return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null));
}
@override
Future<void> 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');
});
}