From bd6f8ba8131ab6f75940961fb9981c478c8cddac Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 12 Mar 2026 20:40:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/googlepay.md | 139 +++-- docs/payment_flow.md | 8 +- lib/core/api/services/payment_api.dart | 6 +- .../google_play_purchase_service.dart | 142 +++-- .../google_pay_verification_payload.dart | 14 + .../recharge/models/payment_method_item.dart | 4 +- lib/features/recharge/recharge_screen.dart | 508 +++++++++++++----- 7 files changed, 565 insertions(+), 256 deletions(-) create mode 100644 lib/features/recharge/models/google_pay_verification_payload.dart diff --git a/docs/googlepay.md b/docs/googlepay.md index 85f139c..d1183a1 100644 --- a/docs/googlepay.md +++ b/docs/googlepay.md @@ -1,34 +1,109 @@ -# 支付界面 -recharge_screen.dart -``` - "extrapolate": "", // note (string) 备注 - "helm": "", // code (string) 产品代码 - "forge": 0, // bonus (int) 赠送积分 - "guardian": "", // actualAmount (string) 实际金额 - "lead": "", // discountOff (string) 折扣 - "glossary": "", // title (string) 标题 - "curriculum": "", // originAmount (string) 原价 - "warrior": "", // activityId (string) 活动ID - "distribute": "", // subscriptionPeriod (string) 订阅周期 - "greaves": 0, // credits (int) 积分数 - "shield": "", // client (string) 客户端 - "species": 0, // days (int) 天数 - "familiar": "", // currency (string) 货币 - "subtract": 0 // productType (int) 产品类型 -``` -其中helm 为google pay的商品ID -## 界面要展示 - 1.价格需显示$符号 字段guardian - 2.原价需显示中划线 字段curriculum - 3.赠送积分 字段forge +# 谷歌支付流程 -# 购买 -从/v1/user/common_info接口里面取到 enable_third_party_payment字段如果这个字段为true则调用第三方支付 -## 三方支付流程 -获取商品列表/v1/payment/getGooglePayActivities -> -传activityId获取支付列表/v1/payment/get-payment-methods -> -创建订单的时候把选择的支付方式的字段填充,创建订单/v1/payment/createPayment -创建订单传app、userId、activityId、paymentMethod、subPaymentMethod(有的话),其他那些先不管 +本文档描述 Android 上 Google Play 内购的完整流程,与 `recharge_screen.dart`、`GooglePlayPurchaseService`、`PaymentApi` 实现对应。 -# 谷歌支付回调接口 -/v1/payment/googlepay +--- + +## 1. 流程总览 + +- **第三方支付开启**(`enable_third_party_payment === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`。 +- **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。 + +``` +用户点击 Buy(某商品) + │ + ├─ 第三方支付开 + 已登录 + │ ├─ getPaymentMethods(activityId) + │ ├─ 弹窗选择支付方式 + │ ├─ 若选「Google Pay」 + │ │ ├─ POST /v1/payment/createPayment → 得到 federation(订单 id) + │ │ ├─ 调起 Google Play 内购(productId = 商品 code/helm) + │ │ └─ 支付成功后 POST /v1/payment/googlepay(见下文) + │ └─ 若选其他方式 → 打开 createPayment 返回的 payUrl + │ + └─ 第三方支付关或未登录 + └─ 仅 Android:直接调起 Google Play 内购(productId = 商品 code),无 createPayment、无 googlepay 回调 +``` + +--- + +## 2. 创建订单(仅第三方 + 选 Google Pay 时) + +| 项目 | 说明 | +|------|------| +| 接口 | `POST /v1/payment/createPayment` | +| 入参 | sentinel, asset(userId), warrior(activityId), resource, ceremony(选 Google Pay 时 resource/ceremony 含 "GooglePay") | +| 关键响应 | **federation**:订单 id,后续 googlepay 回调必传;**convert**:其他支付方式的 payUrl,选 Google Pay 时不使用 | + +- 当返回的 **federation(订单 id)不为空**时,继续调起谷歌支付并可在成功后回调 googlepay。 +- 当返回的 federation 为空时:可按业务要求重试创建订单(如最多 3 次),仍失败则提示失败。 + +--- + +## 3. 调起谷歌支付 + +| 项目 | 说明 | +|------|------| +| 代码 | `GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)`(所有内购统一使用) | +| productId | 当前商品的 **code**(即接口里的 **helm**),须与 Google Play 后台「产品 ID」完全一致 | +| 成功结果 | 返回的凭据用于构造 googlepay 回调 body(见下节) | + +仅 **Android** 执行;非 Android 提示 "Google Pay is only available on Android"。 + +--- + +## 4. 支付成功后回调:`POST /v1/payment/googlepay` + +仅在**第三方支付开 + 选 Google Pay + 已先创建订单**时调用。请求体为 JSON,服务端用于校验并落单。 + +### 4.1 请求体字段(body) + +请求体为四个顶层字段,语义与取值来源对应关系如下(id / purchaseData / signature / userId 分别对应 federation / merchant / sample / asset): + +| 请求体字段 | 含义(填入的值) | 客户端取值来源 | +|------------|------------------|----------------| +| **sample** | 签名(signature) | 谷歌支付成功后,`GooglePlayPurchaseDetails.billingClientPurchase` 的 **signature** | +| **merchant** | 购买凭据 JSON(purchaseData) | 同上 **billingClientPurchase** 的 **originalJson** | +| **federation** | 支付/订单 id(id) | 创建订单接口 `createPayment` 返回的 **federation** | +| **asset** | 用户 id(userId) | 当前登录用户 id(与 createPayment 的 asset 一致) | + +### 4.2 示例 body 结构 + +```json +{ + "sample": "YbOntv0sVOsZ5d4F8hIYdPNSMy9a4+5oAsV/...", + "merchant": "{\"orderId\":\"GPA.3327-0087-2324-9960\",\"packageName\":\"com.xxx.xxxx\",\"productId\":\"com.xxx.xxxx599\",\"purchaseTime\":1773305500428,\"purchaseState\":0,\"purchaseToken\":\"...\",\"quantity\":1,\"acknowledged\":false}", + "federation": "1315538320560683421235", + "asset": "135303839048" +} +``` + +- **federation**:来自 createPayment 的 **federation**(服务端订单 id)。 +- **merchant**:来自 Google Play 的 **originalJson**(整段购买凭据 JSON 字符串)。 +- **sample**:来自 Google Play 的 **signature**,服务端用其校验 merchant。 +- **asset**:当前用户 id。 + +### 4.3 与客户端实现对应 + +- 内购成功后,从 `GooglePlayPurchaseDetails.billingClientPurchase`(`PurchaseWrapper`)取 **originalJson**、**signature**,分别填入请求体的 **merchant**、**sample**。 +- createPayment 返回的 **federation** 填入 **federation**,当前用户 id 填入 **asset**。 + +--- + +## 5. 代码位置速查 + +| 步骤 | 位置 | +|------|------| +| Buy 分支(第三方 vs 直接谷歌) | `recharge_screen.dart`:`_onBuy` → `_runThirdPartyPayment` / `_runGooglePay` | +| 创建订单 | `PaymentApi.createPayment`;调用处在 `_createOrderAndOpenUrl` | +| 调起内购并拿凭据 | `GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)` | +| 回调 googlepay | `PaymentApi.googlepay(...)`,在 `_createOrderAndOpenUrl` 内、内购成功后调用 | +| 凭据数据结构 | Android:`GooglePlayPurchaseDetails.billingClientPurchase`(orderId, originalJson, signature) | + +--- + +## 6. 小结 + +1. **创建订单**:仅在选择「Google Pay」且第三方支付开启时调用 createPayment;拿到 **federation** 后才会调起谷歌支付并回调 googlepay。 +2. **调起谷歌支付**:productId 固定为当前商品的 **code(helm)**,与 Play 后台产品 ID 一致。 +3. **回调 googlepay**:body 为四字段 **sample**(signature)、**merchant**(purchaseData/originalJson)、**federation**(订单 id)、**asset**(userId);federation 为空则不回调,可按策略重试创建订单或提示失败。 diff --git a/docs/payment_flow.md b/docs/payment_flow.md index 997a0c1..1607365 100644 --- a/docs/payment_flow.md +++ b/docs/payment_flow.md @@ -80,7 +80,7 @@ - 条件:`_isGooglePay(paymentMethod, subPaymentMethod)` 为 true(即 paymentMethod 或 subPaymentMethod 转为小写后等于 `"googlepay"`)。 - 仅 Android 执行;非 Android 提示 "Google Pay is only available on Android" 并结束。 -- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**: +- 调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(productId)**(所有内购统一使用): - productId 为当前商品的 **code**(helm)。 - 成功返回 **serverVerificationData**(merchant),失败/取消返回 null。 - 若有 merchant 且订单 ID(federation)存在,调用 **PaymentApi.googlepay(merchant, federation, asset)**;根据返回提示成功或失败并打点(AdjustEvents)。 @@ -89,7 +89,7 @@ ## 5. 直接谷歌支付(enable_third_party_payment !== true) -- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchase(item.code)**,不请求 getPaymentMethods / createPayment。 +- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(item.code)**,不请求 getPaymentMethods / createPayment;凭据可用于后续服务端回调。 - 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。 - 非 Android:提示 "Google Pay is only available on Android"。 @@ -122,8 +122,8 @@ | 支付分支与 Buy 入口 | recharge_screen.dart:_onBuy → enableThirdPartyPayment ? _runThirdPartyPayment : _runGooglePay | | 第三方:获取支付方式 + 弹窗 | _runThirdPartyPayment:PaymentApi.getPaymentMethods → _PaymentMethodDialog | | 第三方:创建订单 + Google Pay / 打开链接 | _createOrderAndOpenUrl:createPayment → _isGooglePay ? launchPurchaseAndReturnData + googlepay : launchUrl(convert) | -| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchase | -| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData / launchPurchase;PaymentApi.googlepay | +| 直接谷歌支付 | _runGooglePay → _launchGooglePlayPurchase → GooglePlayPurchaseService.launchPurchaseAndReturnData | +| 谷歌内购 + 凭据上报 | google_play_purchase_service.dart:launchPurchaseAndReturnData;PaymentApi.googlepay | | 商品未找到排查 | docs/google_pay_product_not_found.md | --- diff --git a/lib/core/api/services/payment_api.dart b/lib/core/api/services/payment_api.dart index 2c7ad06..6cd5f96 100644 --- a/lib/core/api/services/payment_api.dart +++ b/lib/core/api/services/payment_api.dart @@ -88,12 +88,12 @@ abstract final class PaymentApi { ); } - /// Google 支付结果回调(凭据、订单 ID、用户 ID) + /// Google 支付结果回调。body 为 sample(signature)、merchant(purchaseData)、federation(id)、asset(userId),见 docs/googlepay.md。 static Future googlepay({ + required String sample, required String merchant, required String federation, required String asset, - String? sample, }) async { return _client.request( path: '/v1/payment/googlepay', @@ -103,10 +103,10 @@ abstract final class PaymentApi { 'asset': asset, }, body: { + 'sample': sample, 'merchant': merchant, 'federation': federation, 'asset': asset, - if (sample != null && sample.isNotEmpty) 'sample': sample, }, ); } diff --git a/lib/features/recharge/google_play_purchase_service.dart b/lib/features/recharge/google_play_purchase_service.dart index a1a8014..2356f6a 100644 --- a/lib/features/recharge/google_play_purchase_service.dart +++ b/lib/features/recharge/google_play_purchase_service.dart @@ -1,17 +1,22 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:pets_hero_ai/core/api/api.dart'; import '../../core/log/app_logger.dart'; +import 'models/google_pay_verification_payload.dart'; -/// 调起 Google Play 内购(三方为 false 时使用;三方为 true 且 ceremony==GooglePay 时用 launchPurchaseAndReturnData) +/// 调起 Google Play 内购,所有内购均通过本方法发起并返回凭据用于服务端回调。 abstract final class GooglePlayPurchaseService { static final _log = AppLogger('GooglePlayPurchase'); - /// 发起购买并返回服务器凭据(用于三方支付选 GooglePay 时上报 /v1/payment/googlepay)。 - /// 成功返回 purchaseData(serverVerificationData),取消/失败返回 null。 - static Future launchPurchaseAndReturnData(String productId) async { + /// 发起购买并返回服务端回调所需凭据(purchaseData=originalJson, signature)。 + /// 成功返回 [GooglePayVerificationPayload],取消/失败返回 null。调用方填入 id(federation)、userId 后组 merchant 上报。 + static Future launchPurchaseAndReturnData( + String productId) async { _log.d('谷歌支付请求商品 ID(helm): "$productId"'); if (defaultTargetPlatform != TargetPlatform.android) { _log.d('非 Android,跳过内购'); @@ -24,22 +29,69 @@ abstract final class GooglePlayPurchaseService { } final response = await iap.queryProductDetails({productId}); if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) { - _log.w('商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致(区分大小写)'); + _log.w( + '商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致(区分大小写)'); return null; } final product = response.productDetails.first; - final completer = Completer(); + final completer = Completer(); StreamSubscription>? sub; sub = iap.purchaseStream.listen( (purchases) { + // 把 purchases 转为 JSON 输出,方便调试 + try { + final list = purchases.map((p) { + final base = { + 'productID': p.productID, + 'status': p.status.toString(), + 'transactionDate': p.transactionDate, + 'verificationData': { + 'serverVerificationData': + p.verificationData.serverVerificationData, + 'localVerificationData': + p.verificationData.localVerificationData, + 'source': p.verificationData.source, + }, + 'pendingCompletePurchase': p.pendingCompletePurchase, + 'error': p.error?.message, + }; + if (p is GooglePlayPurchaseDetails) { + final b = p.billingClientPurchase; + base['googlePlay'] = { + 'orderId': b.orderId, + 'packageName': b.packageName, + 'purchaseTime': b.purchaseTime, + 'purchaseToken': b.purchaseToken, + 'signature': b.signature, + 'originalJson': b.originalJson, + 'isAcknowledged': b.isAcknowledged, + 'purchaseState': b.purchaseState.toString(), + }; + } + return base; + }).toList(); + _log.d('Google Play purchases json: ${jsonEncode(list)}'); + logWithEmbeddedJson(jsonEncode(list)); + } catch (e) { + _log.w('序列化 purchases 失败: $e'); + } + for (final p in purchases) { if (p.productID != productId) continue; if (p.status == PurchaseStatus.purchased || p.status == PurchaseStatus.restored) { + _log.d('购买成功: ${p.toString()}'); if (!completer.isCompleted) { - final data = p.verificationData.serverVerificationData; + GooglePayVerificationPayload? payload; + if (p is GooglePlayPurchaseDetails) { + final b = p.billingClientPurchase; + payload = GooglePayVerificationPayload( + purchaseData: b.originalJson, + signature: b.signature, + ); + } iap.completePurchase(p); - completer.complete(data); + completer.complete(payload); } sub?.cancel(); return; @@ -52,7 +104,7 @@ abstract final class GooglePlayPurchaseService { } } }, - onError: (_) { + onError: (e) { if (!completer.isCompleted) completer.complete(null); sub?.cancel(); }, @@ -72,76 +124,4 @@ abstract final class GooglePlayPurchaseService { }, ); } - - /// 发起购买,商品 ID 为 [productId](即 ActivityItem.code / helm)。 - /// 返回 true 表示购买完成,false 表示取消或失败。 - static Future launchPurchase(String productId) async { - if (defaultTargetPlatform != TargetPlatform.android) { - return false; - } - - final iap = InAppPurchase.instance; - final available = await iap.isAvailable(); - if (!available) { - return false; - } - - final response = await iap.queryProductDetails({productId}); - if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) { - _log.w('商品未找到: productId="$productId", notFoundIDs=${response.notFoundIDs}'); - return false; - } - - final product = response.productDetails.first; - final completer = Completer(); - StreamSubscription>? sub; - - sub = iap.purchaseStream.listen( - (purchases) { - for (final p in purchases) { - if (p.productID != productId) continue; - if (p.status == PurchaseStatus.purchased || - p.status == PurchaseStatus.restored) { - if (!completer.isCompleted) { - iap.completePurchase(p); - completer.complete(true); - } - sub?.cancel(); - return; - } - if (p.status == PurchaseStatus.error) { - if (!completer.isCompleted) { - completer.complete(false); - } - sub?.cancel(); - return; - } - if (p.status == PurchaseStatus.canceled) { - if (!completer.isCompleted) { - completer.complete(false); - } - sub?.cancel(); - return; - } - } - }, - onError: (Object e) { - if (!completer.isCompleted) completer.complete(false); - sub?.cancel(); - }, - ); - - final purchaseParam = PurchaseParam(productDetails: product); - final success = await iap.buyConsumable(purchaseParam: purchaseParam); - if (!success) { - sub.cancel(); - return false; - } - - return completer.future.timeout(const Duration(seconds: 120), - onTimeout: () { - sub?.cancel(); - return false; - }); - } } diff --git a/lib/features/recharge/models/google_pay_verification_payload.dart b/lib/features/recharge/models/google_pay_verification_payload.dart new file mode 100644 index 0000000..a600530 --- /dev/null +++ b/lib/features/recharge/models/google_pay_verification_payload.dart @@ -0,0 +1,14 @@ +/// 谷歌支付成功后用于构造 /v1/payment/googlepay 的 body。 +/// 本类提供 purchaseData(→ merchant)、signature(→ sample);调用方填入 federation(订单 id)、userId(→ asset)后上报,见 docs/googlepay.md。 +class GooglePayVerificationPayload { + const GooglePayVerificationPayload({ + required this.purchaseData, + required this.signature, + }); + + /// 购买凭据 JSON 字符串(billingClientPurchase.originalJson) + final String purchaseData; + + /// 对 purchaseData 的 RSA 签名(billingClientPurchase.signature) + final String signature; +} diff --git a/lib/features/recharge/models/payment_method_item.dart b/lib/features/recharge/models/payment_method_item.dart index dc1489b..48ab8b3 100644 --- a/lib/features/recharge/models/payment_method_item.dart +++ b/lib/features/recharge/models/payment_method_item.dart @@ -12,7 +12,7 @@ class PaymentMethodItem { final String? subPaymentMethod; // ceremony final String? name; // brigade 展示名称 final String? icon; // greylist 图标 URL - final bool recommend; // deny 取反为推荐 + final bool recommend; // deny 为 true 时显示 Recommended factory PaymentMethodItem.fromJson(Map json) { return PaymentMethodItem( @@ -20,7 +20,7 @@ class PaymentMethodItem { subPaymentMethod: json['ceremony']?.toString(), name: json['brigade']?.toString(), icon: json['greylist']?.toString(), - recommend: json['deny'] != true, + recommend: json['deny'] == true, ); } diff --git a/lib/features/recharge/recharge_screen.dart b/lib/features/recharge/recharge_screen.dart index f47d76f..bfdbf76 100644 --- a/lib/features/recharge/recharge_screen.dart +++ b/lib/features/recharge/recharge_screen.dart @@ -29,10 +29,12 @@ class RechargeScreen extends StatefulWidget { State createState() => _RechargeScreenState(); } -class _RechargeScreenState extends State with WidgetsBindingObserver { +class _RechargeScreenState extends State + with WidgetsBindingObserver { List _activities = []; bool _loadingTiers = true; String? _tierError; + /// 当前正在支付的商品 code,仅该 item 的 Buy 显示 loading String? _loadingProductId; @@ -52,7 +54,9 @@ class _RechargeScreenState extends State with WidgetsBindingObse @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) { + if (state == AppLifecycleState.resumed && + _loadingProductId != null && + mounted) { setState(() => _loadingProductId = null); } } @@ -65,7 +69,7 @@ class _RechargeScreenState extends State with WidgetsBindingObse try { await AuthService.loginComplete; final country = UserState.navigate.value ?? ''; - + final res = defaultTargetPlatform == TargetPlatform.iOS ? await PaymentApi.getApplePayActivities( vambrace: country.isEmpty ? null : country, @@ -122,8 +126,7 @@ class _RechargeScreenState extends State with WidgetsBindingObse AdjustEvents.trackWeeklyVip(); } - final useThirdParty = - UserState.enableThirdPartyPayment.value == true; + final useThirdParty = UserState.enableThirdPartyPayment.value == true; final uid = UserState.userId.value; if (useThirdParty && uid != null && uid.isNotEmpty) { @@ -157,7 +160,9 @@ class _RechargeScreenState extends State with WidgetsBindingObse if (!methodsRes.isSuccess || methodsRes.data == null) { _showSnackBar( context, - methodsRes.msg.isNotEmpty ? methodsRes.msg : 'Failed to load payment methods', + methodsRes.msg.isNotEmpty + ? methodsRes.msg + : 'Failed to load payment methods', isError: true, ); AdjustEvents.trackPaymentFailed(); @@ -165,7 +170,8 @@ class _RechargeScreenState extends State with WidgetsBindingObse } final methodsList = methodsRes.data is Map - ? (methodsRes.data! as Map)['renew'] as List? + ? (methodsRes.data! as Map)['renew'] + as List? : null; if (methodsList == null || methodsList.isEmpty) { _showSnackBar(context, 'No payment methods available', isError: true); @@ -185,9 +191,11 @@ class _RechargeScreenState extends State with WidgetsBindingObse } if (!mounted) return; - final selected = await showDialog( + final selected = await showModalBottomSheet( context: context, - builder: (ctx) => _PaymentMethodDialog(methods: methods), + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => _PaymentMethodSheet(methods: methods), ); if (!mounted) return; @@ -201,7 +209,9 @@ class _RechargeScreenState extends State with WidgetsBindingObse activityId: activityId, productId: item.code, paymentMethod: selected.paymentMethod, - subPaymentMethod: selected.subPaymentMethod?.isEmpty == true ? null : selected.subPaymentMethod, + subPaymentMethod: selected.subPaymentMethod?.isEmpty == true + ? null + : selected.subPaymentMethod, ); } catch (e) { if (mounted) { @@ -251,17 +261,23 @@ class _RechargeScreenState extends State with WidgetsBindingObse if (_isGooglePay(paymentMethod, subPaymentMethod)) { if (defaultTargetPlatform != TargetPlatform.android) { - _showSnackBar(context, 'Google Pay is only available on Android.', isError: true); + _showSnackBar(context, 'Google Pay is only available on Android.', + isError: true); AdjustEvents.trackPaymentFailed(); return; } - final purchaseData = await GooglePlayPurchaseService.launchPurchaseAndReturnData(productId); + if (mounted) setState(() => _loadingProductId = null); + final payload = + await GooglePlayPurchaseService.launchPurchaseAndReturnData( + productId); if (!mounted) return; - if (purchaseData != null && purchaseData.isNotEmpty && orderId != null && orderId.isNotEmpty) { - RechargeScreen._log.d('googlepay 入参: federation=$orderId, asset=$userId'); - RechargeScreen._log.d('googlepay 入参: merchant(length=${purchaseData.length}) ${purchaseData.length > 400 ? "${purchaseData.substring(0, 400)}..." : purchaseData}'); + if (payload != null && + orderId != null && + orderId.isNotEmpty) { + RechargeScreen._log.d('googlepay 入参: federation=$orderId'); final googlepayRes = await PaymentApi.googlepay( - merchant: purchaseData, + sample: payload.signature, + merchant: payload.purchaseData, federation: orderId, asset: userId, ); @@ -270,11 +286,17 @@ class _RechargeScreenState extends State with WidgetsBindingObse _showSnackBar(context, 'Purchase completed.'); AdjustEvents.trackPurchaseSuccess(); } else { - _showSnackBar(context, googlepayRes.msg.isNotEmpty ? googlepayRes.msg : 'Payment verification failed.', isError: true); + _showSnackBar( + context, + googlepayRes.msg.isNotEmpty + ? googlepayRes.msg + : 'Payment verification failed.', + isError: true); AdjustEvents.trackPaymentFailed(); } } else { - _showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); + _showSnackBar(context, 'Purchase was cancelled or failed.', + isError: true); AdjustEvents.trackPaymentFailed(); } return; @@ -283,17 +305,21 @@ class _RechargeScreenState extends State with WidgetsBindingObse final payUrl = data?['convert']?.toString(); if (payUrl != null && payUrl.isNotEmpty) { if (mounted) { + setState(() => _loadingProductId = null); await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PaymentWebViewScreen(paymentUrl: payUrl), ), ); - _showSnackBar(context, 'Order created. Complete payment in the page.'); + _showSnackBar( + context, 'Order created. Complete payment in the page.'); AdjustEvents.trackPurchaseSuccess(); } } else { if (mounted) { - _showSnackBar(context, 'Order created. Awaiting payment confirmation.'); + setState(() => _loadingProductId = null); + _showSnackBar( + context, 'Order created. Awaiting payment confirmation.'); } AdjustEvents.trackPurchaseSuccess(); } @@ -319,30 +345,37 @@ class _RechargeScreenState extends State with WidgetsBindingObse /// 三方为 false 时走谷歌应用内支付 Future _runGooglePay(ActivityItem item) async { if (defaultTargetPlatform != TargetPlatform.android) { - _showSnackBar(context, 'Google Pay is only available on Android.', isError: true); + _showSnackBar(context, 'Google Pay is only available on Android.', + isError: true); AdjustEvents.trackPaymentFailed(); return; } await _launchGooglePlayPurchase(item); } - /// 调起 Google Play 内购(商品 ID = item.code / helm) + /// 调起 Google Play 内购(商品 ID = item.code / helm),凭据用于服务端回调 Future _launchGooglePlayPurchase(ActivityItem item) async { if (!mounted) return; try { - final success = await GooglePlayPurchaseService.launchPurchase(item.code); + if (mounted) setState(() => _loadingProductId = null); + final payload = + await GooglePlayPurchaseService.launchPurchaseAndReturnData( + item.code); if (!mounted) return; - if (success) { + if (payload != null) { _showSnackBar(context, 'Purchase completed.'); AdjustEvents.trackPurchaseSuccess(); + // 直接谷歌支付路径:如需回调服务端,需先 createPayment 取得 federation,再上报 googlepay } else { - _showSnackBar(context, 'Purchase was cancelled or failed.', isError: true); + _showSnackBar(context, 'Purchase was cancelled or failed.', + isError: true); AdjustEvents.trackPaymentFailed(); } } catch (e) { if (mounted) { - _showSnackBar(context, 'Google Pay error: ${e.toString()}', isError: true); + _showSnackBar(context, 'Google Pay error: ${e.toString()}', + isError: true); } AdjustEvents.trackPaymentFailed(); } finally { @@ -352,7 +385,8 @@ class _RechargeScreenState extends State with WidgetsBindingObse } } - void _showSnackBar(BuildContext context, String message, {bool isError = false}) { + void _showSnackBar(BuildContext context, String message, + {bool isError = false}) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -371,95 +405,209 @@ class _RechargeScreenState extends State with WidgetsBindingObse child: TopNavBar( title: 'Recharge', showBackButton: true, - onBack: () => Navigator.of(context).pop(), + onBack: _loadingProductId != null + ? () {} + : () => Navigator.of(context).pop(), ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CreditsSection( - currentCredits: - UserCreditsData.of(context)?.creditsDisplay ?? '--', - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.screenPaddingLarge, - vertical: AppSpacing.xxl, - ), - child: Text( - 'Select Tier', - style: AppTypography.bodyMedium.copyWith( - color: AppColors.textPrimary, + body: Stack( + children: [ + SingleChildScrollView( + padding: + const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CreditsSection( + currentCredits: + UserCreditsData.of(context)?.creditsDisplay ?? '--', ), - ), - ), - if (_loadingTiers) - const Padding( - padding: EdgeInsets.all(AppSpacing.xxl), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.screenPaddingLarge, + vertical: AppSpacing.xxl, + ), + child: Text( + 'Select Tier', + style: AppTypography.bodyMedium.copyWith( + color: AppColors.textPrimary, + ), ), ), - ) - else if (_tierError != null) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.screenPadding), - child: Column( - children: [ - Text( - _tierError!, + if (_loadingTiers) + const Padding( + padding: EdgeInsets.all(AppSpacing.xxl), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (_tierError != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.screenPadding), + child: Column( + children: [ + Text( + _tierError!, + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.md), + TextButton( + onPressed: _fetchActivities, + child: const Text('Retry'), + ), + ], + ), + ) + else if (_activities.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.screenPadding), + child: Text( + 'No packages available', style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, ), ), - const SizedBox(height: AppSpacing.md), - TextButton( - onPressed: _fetchActivities, - child: const Text('Retry'), + ) + else + ...List.generate(_activities.length, (i) { + final item = _activities[i]; + final isRecommended = false; + final isPopular = false; + return Padding( + padding: EdgeInsets.only( + bottom: i < _activities.length - 1 ? AppSpacing.xl : 0, + ), + child: _TierCardFromActivity( + item: item, + badge: isRecommended + ? _TierBadge.recommended + : isPopular + ? _TierBadge.popular + : _TierBadge.none, + loading: _loadingProductId == item.code, + onBuy: () => _onBuy(item), + ), + ); + }), + ], + ), + ), + if (_loadingProductId != null) + Positioned.fill( + child: IgnorePointer( + child: Container( + color: AppColors.overlayDark, + child: const Center( + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(AppColors.surface), + ), ), - ], + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 支付方式选择弹窗(三方支付列表)- 1:1 匹配 Pencil PaymentMethodModal +class _PaymentMethodSheet extends StatefulWidget { + const _PaymentMethodSheet({required this.methods}); + + final List methods; + + @override + State<_PaymentMethodSheet> createState() => _PaymentMethodSheetState(); +} + +class _PaymentMethodSheetState extends State<_PaymentMethodSheet> { + PaymentMethodItem? _selected; + + @override + void initState() { + super.initState(); + _selected = widget.methods.first; + } + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + return Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + padding: EdgeInsets.fromLTRB(24, 20, 24, 32 + bottomPadding), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Select Payment Method', + style: AppTypography.bodyLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + const SizedBox(height: 20), + ...List.generate(widget.methods.length, (i) { + final m = widget.methods[i]; + final isSelected = _selected == m; + return Padding( + padding: EdgeInsets.only( + bottom: i < widget.methods.length - 1 ? 12 : 0, + ), + child: _PaymentMethodItem( + item: m, + isSelected: isSelected, + onTap: () => setState(() => _selected = m), + ), + ); + }), + const SizedBox(height: 20), + SizedBox( + height: 48, + child: ElevatedButton( + onPressed: _selected != null + ? () => Navigator.of(context).pop(_selected) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), - ) - else if (_activities.isEmpty) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.screenPadding), child: Text( - 'No packages available', + 'Pay', style: AppTypography.bodyRegular.copyWith( - color: AppColors.textSecondary, + color: AppColors.surface, + fontWeight: FontWeight.w600, + fontSize: 16, ), ), - ) - else - ...List.generate(_activities.length, (i) { - final item = _activities[i]; - final isRecommended = i == 1; - final isPopular = i == 2; - return Padding( - padding: EdgeInsets.only( - bottom: i < _activities.length - 1 - ? AppSpacing.xl - : 0, - ), - child: _TierCardFromActivity( - item: item, - badge: isRecommended - ? _TierBadge.recommended - : isPopular - ? _TierBadge.popular - : _TierBadge.none, - loading: _loadingProductId == item.code, - onBuy: () => _onBuy(item), - ), - ); - }), + ), + ), ], ), ), @@ -467,41 +615,130 @@ class _RechargeScreenState extends State with WidgetsBindingObse } } -/// 支付方式选择弹窗(三方支付列表) -class _PaymentMethodDialog extends StatelessWidget { - const _PaymentMethodDialog({required this.methods}); +/// 单条支付方式项 - 图标、名称、Recommended(deny取反)、单选 +class _PaymentMethodItem extends StatelessWidget { + const _PaymentMethodItem({ + required this.item, + required this.isSelected, + required this.onTap, + }); - final List methods; + final PaymentMethodItem item; + final bool isSelected; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Select payment method'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: methods - .map( - (m) => ListTile( - title: Text(m.displayName), - subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty - ? Text(m.subPaymentMethod!, style: AppTypography.caption) - : null, - trailing: m.recommend - ? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary)) - : null, - onTap: () => Navigator.of(context).pop(m), - ), - ) - .toList(), + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + height: 64, + decoration: BoxDecoration( + color: isSelected ? const Color(0x158B5CF6) : AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary : AppColors.border, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + _PaymentIcon(iconUrl: item.icon), + const SizedBox(width: 12), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + item.displayName, + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (item.recommend) ...[ + const SizedBox(width: 6), + Text( + 'Recommended', + style: AppTypography.caption.copyWith( + color: AppColors.primary, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ], + ], + ), + ), + ], + ), + ), + _RadioIndicator(selected: isSelected), + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + ); + } +} + +/// 支付方式图标 - 优先接口 greylist,否则 fallback,正方形 40x40(在 64 高度内最大),完整显示不裁切不变形 +class _PaymentIcon extends StatelessWidget { + const _PaymentIcon({this.iconUrl}); + + final String? iconUrl; + + static const double _size = 40; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: _size, + child: iconUrl != null && iconUrl!.isNotEmpty + ? Image.network( + iconUrl!, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + LucideIcons.credit_card, + size: 24, + color: AppColors.primary, + ), + ) + : Icon( + LucideIcons.credit_card, + size: 24, + color: AppColors.primary, + ), + ); + } +} + +/// 单选指示器 - 选中实心紫,未选中空心灰 +class _RadioIndicator extends StatelessWidget { + const _RadioIndicator({required this.selected}); + + final bool selected; + + @override + Widget build(BuildContext context) { + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? AppColors.primary : Colors.transparent, + border: Border.all( + color: selected ? AppColors.primary : AppColors.border, + width: selected ? 0 : 1, ), - ], + ), ); } } @@ -554,7 +791,10 @@ class _TierCardFromActivity extends StatelessWidget { color: AppColors.textPrimary, ), ), - SizedBox(height: badge == _TierBadge.none ? AppSpacing.xs : AppSpacing.sm), + SizedBox( + height: badge == _TierBadge.none + ? AppSpacing.xs + : AppSpacing.sm), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, @@ -623,9 +863,7 @@ class _TierCardFromActivity extends StatelessWidget { ), ), child: Text( - badge == _TierBadge.recommended - ? 'Recommended' - : 'Most Popular', + badge == _TierBadge.recommended ? 'Recommended' : 'Most Popular', style: AppTypography.label.copyWith( color: badge == _TierBadge.recommended ? AppColors.primary @@ -708,7 +946,9 @@ class _BuyButton extends StatelessWidget { vertical: AppSpacing.md, ), decoration: BoxDecoration( - color: loading ? AppColors.primary.withValues(alpha: 0.7) : AppColors.primary, + color: loading + ? AppColors.primary.withValues(alpha: 0.7) + : AppColors.primary, borderRadius: BorderRadius.circular(12), ), child: loading