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:
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user