Files
pangolin/client/lib/state/payment_provider.dart
T

184 lines
5.6 KiB
Dart

// 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),
);