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,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),
|
||||
);
|
||||
Reference in New Issue
Block a user