pay_service.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. /// 支付编排服务:7 种支付渠道的统一入口
  2. ///
  3. /// 统一原则(与 docs 一致):
  4. /// - 客户端回调结果仅做 UI 提示
  5. /// - 最终支付状态一律以 `GET /v1/payment/{payment_id}` 后端查询为准
  6. /// - Mock 模式(无真实商户密钥)下,微信 Native 走扫码弹窗内「模拟支付成功」,
  7. /// 网页跳转渠道走「模拟支付成功(Mock)」按钮,实现端到端演示
  8. library;
  9. import 'dart:async';
  10. import 'package:flutter/material.dart';
  11. import 'package:flutter_stripe/flutter_stripe.dart';
  12. import 'package:url_launcher/url_launcher.dart';
  13. import '../models/payment.dart';
  14. import '../pages/wechat_qr_dialog.dart';
  15. import '../utils/dialog.dart';
  16. import '../utils/pay_result.dart';
  17. import '../utils/platform.dart';
  18. import '../utils/toast.dart';
  19. import 'api_service.dart';
  20. import 'platform/alipay_interface.dart';
  21. import 'platform/stripe_flow.dart';
  22. import 'platform/wechat_flow.dart';
  23. /// 支付编排服务(单例)
  24. class PayService {
  25. PayService._() {
  26. // 注册微信支付结果回调(App 启动后即可接收)
  27. WechatFlow.instance.onPayResult = _onWechatResult;
  28. }
  29. static final PayService instance = PayService._();
  30. final APIServer _api = APIServer();
  31. /// 微信 App 支付结果等待器(pay 拉起后阻塞直到 SDK 回调)
  32. Completer<int>? _wechatCompleter;
  33. /// 微信 SDK 回调:errCode 0=成功 / -1=失败 / -2=取消
  34. void _onWechatResult(int errCode, String errStr) {
  35. final c = _wechatCompleter;
  36. if (c != null && !c.isCompleted) c.complete(errCode);
  37. }
  38. /// 打开外部链接(网页支付跳转)
  39. Future<void> _launchUrl(String url) async {
  40. final ok = await launchUrl(
  41. Uri.parse(url),
  42. mode: LaunchMode.externalApplication,
  43. );
  44. if (!ok) showErrorMessage('无法打开支付页面:$url');
  45. }
  46. /// 最终状态确认:以后端查询为准(最多轮询约 12s 覆盖回调延迟),
  47. /// 成功后弹出「支付结果」对话框展示订单号与渠道,回到商品页。
  48. Future<void> _queryFinalStatus(
  49. BuildContext context,
  50. String paymentId, {
  51. required String channelName,
  52. }) async {
  53. for (int i = 0; i < 12; i++) {
  54. try {
  55. final status = await _api.queryPaymentStatus(paymentId);
  56. if (status.success) {
  57. if (context.mounted) {
  58. await showPayResultDialog(
  59. context,
  60. success: true,
  61. paymentId: paymentId,
  62. channelName: channelName,
  63. note: status.note,
  64. );
  65. }
  66. return;
  67. }
  68. if ((status.note ?? '').contains('关闭')) {
  69. showErrorMessage('订单已关闭:${status.note}');
  70. return;
  71. }
  72. } catch (_) {
  73. // 网络抖动重试
  74. }
  75. await Future.delayed(const Duration(seconds: 1));
  76. }
  77. // 轮询超时仍未入账:提示用户稍后查询
  78. showErrorMessage('支付结果确认中,请稍后在订单中查询');
  79. }
  80. /// 网页跳转支付确认弹窗
  81. ///
  82. /// 用于支付宝 WAP / 收银台跳转类渠道:
  83. /// - 打开支付页:真实模式,用户完成付款后回来
  84. /// - 模拟支付成功(Mock):演示环境直接走通
  85. Future<void> _handleWebRedirect({
  86. required BuildContext context,
  87. required String url,
  88. required String paymentId,
  89. required String channelName,
  90. }) async {
  91. final choice = await showDialog<String>(
  92. context: context,
  93. builder: (ctx) => AlertDialog(
  94. title: const Text('网页支付'),
  95. content: SingleChildScrollView(
  96. child: Text('请在支付页面完成付款。\n\n支付单号:$paymentId\n支付地址:$url'),
  97. ),
  98. actions: [
  99. TextButton(
  100. onPressed: () => Navigator.of(ctx).pop('open'),
  101. child: const Text('打开支付页'),
  102. ),
  103. TextButton(
  104. onPressed: () => Navigator.of(ctx).pop('simulate'),
  105. child: const Text('模拟支付成功(Mock)'),
  106. ),
  107. TextButton(
  108. onPressed: () => Navigator.of(ctx).pop('cancel'),
  109. child: const Text('取消'),
  110. ),
  111. ],
  112. ),
  113. );
  114. switch (choice) {
  115. case 'open':
  116. await _launchUrl(url);
  117. if (!context.mounted) return;
  118. await _queryFinalStatus(context, paymentId, channelName: channelName);
  119. case 'simulate':
  120. startLoading(status: '模拟支付中...');
  121. try {
  122. await _api.simulatePaymentSuccess(paymentId);
  123. stopLoading();
  124. showSuccessMessage('模拟支付成功');
  125. if (!context.mounted) return;
  126. await _queryFinalStatus(context, paymentId, channelName: channelName);
  127. } on Exception catch (e) {
  128. stopLoading();
  129. showErrorMessage(_api.resolveError(e));
  130. }
  131. default:
  132. return;
  133. }
  134. }
  135. // ===== 1. 微信支付 =====
  136. /// 微信支付
  137. /// - 移动端:App 支付(fluwx 拉起微信)
  138. /// - Web/桌面:Native 扫码(二维码弹窗轮询)
  139. Future<void> payWithWechat(
  140. BuildContext context, {
  141. required String productId,
  142. }) async {
  143. startLoading(status: '正在发起微信支付...');
  144. try {
  145. final created = await _api.createWechatPayment(
  146. productId: productId,
  147. source: PlatformTool.isMobile ? 'app' : 'web',
  148. );
  149. stopLoading();
  150. if (PlatformTool.isMobile) {
  151. // ---- App 支付 ----
  152. final installed = await WechatFlow.instance.isInstalled();
  153. if (!installed) {
  154. showErrorMessage('未安装微信,请安装后重试');
  155. return;
  156. }
  157. final completer = _wechatCompleter = Completer<int>();
  158. final invoked = await WechatFlow.instance.payWith(created);
  159. if (!invoked) {
  160. _wechatCompleter = null;
  161. showErrorMessage('拉起微信失败');
  162. return;
  163. }
  164. // 等待微信 SDK 回调(超时哨兵值 -999,避免永远阻塞)
  165. final errCode = await completer.future
  166. .timeout(const Duration(seconds: 20), onTimeout: () => -999);
  167. _wechatCompleter = null;
  168. switch (errCode) {
  169. case WechatPayResultCode.success:
  170. showSuccessMessage('支付成功');
  171. case WechatPayResultCode.cancelled:
  172. showErrorMessage('已取消支付');
  173. case -999:
  174. showErrorMessage('支付结果确认中,请稍后查询');
  175. default:
  176. showErrorMessage('支付失败($errCode)');
  177. }
  178. // 最终以后端状态为准
  179. if (!context.mounted) return;
  180. await _queryFinalStatus(context, created.paymentId,
  181. channelName: '微信支付');
  182. } else {
  183. // ---- Web/桌面:Native 扫码 ----
  184. final codeUrl = created.codeUrl;
  185. if (codeUrl == null || codeUrl.isEmpty) {
  186. showErrorMessage('未获取到支付二维码');
  187. return;
  188. }
  189. if (!context.mounted) return;
  190. await openDialog(
  191. context,
  192. builder: (_) => WechatQrDialog(
  193. codeUrl: codeUrl,
  194. paymentId: created.paymentId,
  195. onClose: (success) {
  196. // 扫码弹窗关闭后回到商品页,弹出支付结果(失败时给出提示)
  197. if (!success) {
  198. showErrorMessage('订单已关闭或未完成支付');
  199. }
  200. },
  201. ),
  202. );
  203. }
  204. } on Exception catch (e) {
  205. stopLoading();
  206. showErrorMessage(_api.resolveError(e));
  207. }
  208. }
  209. // ===== 2. 支付宝支付 =====
  210. /// 支付宝支付
  211. /// - 移动端:App 支付(alipay_kit 拉起支付宝)
  212. /// - Web:手机网站支付(后端返回 WAP 跳转链接)
  213. Future<void> payWithAlipay(
  214. BuildContext context, {
  215. required String productId,
  216. }) async {
  217. startLoading(status: '正在发起支付宝支付...');
  218. try {
  219. final created = await _api.createAlipayPayment(
  220. productId: productId,
  221. source: PlatformTool.isMobile ? 'app' : 'web',
  222. );
  223. stopLoading();
  224. if (PlatformTool.isMobile) {
  225. // ---- App 支付 ----
  226. final result = await AlipayFlow.instance.pay(created.params);
  227. if (result.isSuccessful) {
  228. showSuccessMessage('支付成功');
  229. } else if (result.isCancelled) {
  230. showErrorMessage('已取消支付');
  231. } else {
  232. showErrorMessage('支付失败:${result.memo ?? '未知错误'}');
  233. }
  234. // 最终以后端状态为准
  235. if (!context.mounted) return;
  236. await _queryFinalStatus(context, created.paymentId,
  237. channelName: '支付宝支付');
  238. } else {
  239. // ---- Web:WAP 跳转 ----
  240. final url = created.redirectUrl;
  241. if (url == null || url.isEmpty) {
  242. showErrorMessage('未获取到支付跳转链接');
  243. return;
  244. }
  245. if (!context.mounted) return;
  246. await _handleWebRedirect(
  247. context: context,
  248. url: url,
  249. paymentId: created.paymentId,
  250. channelName: '支付宝支付',
  251. );
  252. }
  253. } on Exception catch (e) {
  254. stopLoading();
  255. showErrorMessage(_api.resolveError(e));
  256. }
  257. }
  258. // ===== 3. 银联云闪付 =====
  259. /// 云闪付支付
  260. /// - 移动端:App 支付(后端返回 tn,拉起云闪付 App)
  261. /// - Web/桌面:手机网站跳转(银联收银台)
  262. Future<void> payWithUnionPay(
  263. BuildContext context, {
  264. required String productId,
  265. }) async {
  266. startLoading(status: '正在发起云闪付支付...');
  267. try {
  268. final created = await _api.createUnionPayPayment(
  269. productId: productId,
  270. source: PlatformTool.isMobile ? 'app' : 'web',
  271. );
  272. stopLoading();
  273. if (PlatformTool.isMobile) {
  274. // ---- App 支付:tn 是调起云闪付 App 的唯一凭证 ----
  275. final tn = created.tn;
  276. if (tn == null || tn.isEmpty) {
  277. showErrorMessage('未获取到云闪付交易流水号');
  278. return;
  279. }
  280. // Mock 模式:tn 以 mock_ 开头,不拉起 App,直接模拟支付成功
  281. if (tn.startsWith('mock_')) {
  282. startLoading(status: '模拟支付中...');
  283. await _api.simulatePaymentSuccess(created.paymentId);
  284. stopLoading();
  285. showSuccessMessage('模拟云闪付支付成功');
  286. if (!context.mounted) return;
  287. await _queryFinalStatus(context, created.paymentId,
  288. channelName: '云闪付');
  289. return;
  290. }
  291. // 真实模式:通过 uppay:// scheme 调起云闪付 App
  292. // (正式接入推荐集成银联官方 SDK 拉起,此处用 scheme 演示)
  293. final invoked = await launchUrl(
  294. Uri.parse('uppay://sdkpay?tn=$tn'),
  295. mode: LaunchMode.externalApplication,
  296. );
  297. if (!invoked) {
  298. showErrorMessage('拉起云闪付失败,请确认已安装云闪付 App');
  299. return;
  300. }
  301. // 用户支付完成后回到 App,轮询后端确认最终状态
  302. if (!context.mounted) return;
  303. await _queryFinalStatus(context, created.paymentId,
  304. channelName: '云闪付');
  305. } else {
  306. // ---- Web:银联收银台跳转 ----
  307. final url = created.redirectUrl;
  308. if (url == null || url.isEmpty) {
  309. showErrorMessage('未获取到云闪付跳转链接');
  310. return;
  311. }
  312. if (!context.mounted) return;
  313. await _handleWebRedirect(
  314. context: context,
  315. url: url,
  316. paymentId: created.paymentId,
  317. channelName: '云闪付',
  318. );
  319. }
  320. } on Exception catch (e) {
  321. stopLoading();
  322. showErrorMessage(_api.resolveError(e));
  323. }
  324. }
  325. // ===== 5. Stripe PaymentSheet =====
  326. /// Stripe 支付(PaymentSheet 卡片支付)
  327. Future<void> payWithStripe(
  328. BuildContext context, {
  329. required String productId,
  330. }) async {
  331. startLoading(status: '正在创建 Stripe 支付...');
  332. try {
  333. final created = await _api.createStripePayment(productId: productId);
  334. stopLoading();
  335. // Mock 模式(pk_test_mock):不弹原生面板,直接模拟成功
  336. if (StripeFlow.isMock(created)) {
  337. startLoading(status: '模拟支付中...');
  338. await _api.simulatePaymentSuccess(created.paymentId);
  339. stopLoading();
  340. showSuccessMessage('模拟 Stripe 支付成功');
  341. return;
  342. }
  343. // 真实模式:弹出 PaymentSheet
  344. await StripeFlow.instance.presentPaymentSheet(created);
  345. if (!context.mounted) return;
  346. await _queryFinalStatus(context, created.paymentId,
  347. channelName: 'Stripe 支付');
  348. } on StripeException catch (e) {
  349. stopLoading();
  350. final cancelled = e.error.code == FailureCode.Canceled;
  351. showErrorMessage(
  352. cancelled ? '已取消支付' : '支付失败:${e.error.localizedMessage}',
  353. );
  354. } on Exception catch (e) {
  355. stopLoading();
  356. showErrorMessage(_api.resolveError(e));
  357. }
  358. }
  359. // ===== 6. Apple Pay =====
  360. /// Apple Pay(经 Stripe 确认)
  361. Future<void> payWithApple(
  362. BuildContext context, {
  363. required PaymentProduct product,
  364. }) async {
  365. startLoading(status: '正在创建 Apple Pay...');
  366. try {
  367. final created = await _api.createApplePayment(productId: product.id);
  368. stopLoading();
  369. if (StripeFlow.isMock(created)) {
  370. startLoading(status: '模拟支付中...');
  371. await _api.simulatePaymentSuccess(created.paymentId);
  372. stopLoading();
  373. showSuccessMessage('模拟 Apple Pay 成功');
  374. return;
  375. }
  376. if (!await StripeFlow.instance.isApplePaySupported()) {
  377. showErrorMessage('当前设备不支持 Apple Pay');
  378. return;
  379. }
  380. await StripeFlow.instance.confirmApplePay(
  381. created,
  382. product.name,
  383. product.retailPriceUSD,
  384. );
  385. if (!context.mounted) return;
  386. await _queryFinalStatus(context, created.paymentId,
  387. channelName: 'Apple Pay');
  388. } on StripeException catch (e) {
  389. stopLoading();
  390. showErrorMessage('Apple Pay 失败:${e.error.localizedMessage}');
  391. } on Exception catch (e) {
  392. stopLoading();
  393. showErrorMessage(_api.resolveError(e));
  394. }
  395. }
  396. // ===== 7. Google Pay =====
  397. /// Google Pay(经 Stripe 确认)
  398. Future<void> payWithGoogle(
  399. BuildContext context, {
  400. required PaymentProduct product,
  401. }) async {
  402. startLoading(status: '正在创建 Google Pay...');
  403. try {
  404. final created = await _api.createGooglePayment(productId: product.id);
  405. stopLoading();
  406. if (StripeFlow.isMock(created)) {
  407. startLoading(status: '模拟支付中...');
  408. await _api.simulatePaymentSuccess(created.paymentId);
  409. stopLoading();
  410. showSuccessMessage('模拟 Google Pay 成功');
  411. return;
  412. }
  413. if (!await StripeFlow.instance.isGooglePaySupported()) {
  414. showErrorMessage('当前设备不支持 Google Pay');
  415. return;
  416. }
  417. await StripeFlow.instance.confirmGooglePay(
  418. created,
  419. product.name,
  420. product.retailPriceUSD,
  421. );
  422. if (!context.mounted) return;
  423. await _queryFinalStatus(context, created.paymentId,
  424. channelName: 'Google Pay');
  425. } on StripeException catch (e) {
  426. stopLoading();
  427. showErrorMessage('Google Pay 失败:${e.error.localizedMessage}');
  428. } on Exception catch (e) {
  429. stopLoading();
  430. showErrorMessage(_api.resolveError(e));
  431. }
  432. }
  433. // ===== 8. 网页收银台 =====
  434. /// 网页收银台(聚合支付)
  435. /// - wechat:Native 扫码弹窗轮询
  436. /// - alipay / stripe:跳转 + 确认
  437. Future<void> payWithCheckout(
  438. BuildContext context, {
  439. required String channel,
  440. required String productId,
  441. }) async {
  442. startLoading(status: '正在创建收银台会话...');
  443. try {
  444. final session = await _api.createCheckoutSession(
  445. channel: channel,
  446. productId: productId,
  447. );
  448. stopLoading();
  449. if (channel == 'wechat') {
  450. final codeUrl = session.codeUrl;
  451. if (codeUrl == null || codeUrl.isEmpty) {
  452. showErrorMessage('未获取到支付二维码');
  453. return;
  454. }
  455. if (!context.mounted) return;
  456. await openDialog(
  457. context,
  458. builder: (_) => WechatQrDialog(
  459. codeUrl: codeUrl,
  460. paymentId: session.paymentId,
  461. onClose: (success) {
  462. // 扫码弹窗关闭后回到商品页,失败时给出提示
  463. if (!success) {
  464. showErrorMessage('订单已关闭或未完成支付');
  465. }
  466. },
  467. ),
  468. );
  469. } else {
  470. final url = session.redirectUrl;
  471. if (url == null || url.isEmpty) {
  472. showErrorMessage('未获取到支付跳转链接');
  473. return;
  474. }
  475. if (!context.mounted) return;
  476. // 收银台渠道展示名(与渠道选择弹窗一致)
  477. final displayName = switch (channel) {
  478. 'wechat' => '微信支付',
  479. 'alipay' => '支付宝支付',
  480. _ => 'Stripe 支付',
  481. };
  482. await _handleWebRedirect(
  483. context: context,
  484. url: url,
  485. paymentId: session.paymentId,
  486. channelName: displayName,
  487. );
  488. }
  489. } on Exception catch (e) {
  490. stopLoading();
  491. showErrorMessage(_api.resolveError(e));
  492. }
  493. }
  494. }