优化:支付回调
This commit is contained in:
parent
6ae2b55677
commit
bd6f8ba813
@ -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 为空则不回调,可按策略重试创建订单或提示失败。
|
||||
|
||||
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@ -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<ApiResponse> 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<String?> launchPurchaseAndReturnData(String productId) async {
|
||||
/// 发起购买并返回服务端回调所需凭据(purchaseData=originalJson, signature)。
|
||||
/// 成功返回 [GooglePayVerificationPayload],取消/失败返回 null。调用方填入 id(federation)、userId 后组 merchant 上报。
|
||||
static Future<GooglePayVerificationPayload?> 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<String?>();
|
||||
final completer = Completer<GooglePayVerificationPayload?>();
|
||||
StreamSubscription<List<PurchaseDetails>>? sub;
|
||||
sub = iap.purchaseStream.listen(
|
||||
(purchases) {
|
||||
// 把 purchases 转为 JSON 输出,方便调试
|
||||
try {
|
||||
final list = purchases.map((p) {
|
||||
final base = <String, Object?>{
|
||||
'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<bool> 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<bool>();
|
||||
StreamSubscription<List<PurchaseDetails>>? 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -29,10 +29,12 @@ class RechargeScreen extends StatefulWidget {
|
||||
State<RechargeScreen> createState() => _RechargeScreenState();
|
||||
}
|
||||
|
||||
class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObserver {
|
||||
class _RechargeScreenState extends State<RechargeScreen>
|
||||
with WidgetsBindingObserver {
|
||||
List<ActivityItem> _activities = [];
|
||||
bool _loadingTiers = true;
|
||||
String? _tierError;
|
||||
|
||||
/// 当前正在支付的商品 code,仅该 item 的 Buy 显示 loading
|
||||
String? _loadingProductId;
|
||||
|
||||
@ -52,7 +54,9 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && _loadingProductId != null && mounted) {
|
||||
if (state == AppLifecycleState.resumed &&
|
||||
_loadingProductId != null &&
|
||||
mounted) {
|
||||
setState(() => _loadingProductId = null);
|
||||
}
|
||||
}
|
||||
@ -122,8 +126,7 @@ class _RechargeScreenState extends State<RechargeScreen> 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<RechargeScreen> 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<RechargeScreen> with WidgetsBindingObse
|
||||
}
|
||||
|
||||
final methodsList = methodsRes.data is Map<String, dynamic>
|
||||
? (methodsRes.data! as Map<String, dynamic>)['renew'] as List<dynamic>?
|
||||
? (methodsRes.data! as Map<String, dynamic>)['renew']
|
||||
as List<dynamic>?
|
||||
: null;
|
||||
if (methodsList == null || methodsList.isEmpty) {
|
||||
_showSnackBar(context, 'No payment methods available', isError: true);
|
||||
@ -185,9 +191,11 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<PaymentMethodItem>(
|
||||
final selected = await showModalBottomSheet<PaymentMethodItem>(
|
||||
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<RechargeScreen> 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<RechargeScreen> 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<RechargeScreen> 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<RechargeScreen> with WidgetsBindingObse
|
||||
final payUrl = data?['convert']?.toString();
|
||||
if (payUrl != null && payUrl.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() => _loadingProductId = null);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<RechargeScreen> with WidgetsBindingObse
|
||||
/// 三方为 false 时走谷歌应用内支付
|
||||
Future<void> _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<void> _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<RechargeScreen> 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,11 +405,16 @@ class _RechargeScreenState extends State<RechargeScreen> 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),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: AppSpacing.screenPaddingLarge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -440,13 +479,11 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
|
||||
else
|
||||
...List.generate(_activities.length, (i) {
|
||||
final item = _activities[i];
|
||||
final isRecommended = i == 1;
|
||||
final isPopular = i == 2;
|
||||
final isRecommended = false;
|
||||
final isPopular = false;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i < _activities.length - 1
|
||||
? AppSpacing.xl
|
||||
: 0,
|
||||
bottom: i < _activities.length - 1 ? AppSpacing.xl : 0,
|
||||
),
|
||||
child: _TierCardFromActivity(
|
||||
item: item,
|
||||
@ -463,45 +500,245 @@ class _RechargeScreenState extends State<RechargeScreen> with WidgetsBindingObse
|
||||
],
|
||||
),
|
||||
),
|
||||
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<Color>(AppColors.surface),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付方式选择弹窗(三方支付列表)
|
||||
class _PaymentMethodDialog extends StatelessWidget {
|
||||
const _PaymentMethodDialog({required this.methods});
|
||||
/// 支付方式选择弹窗(三方支付列表)- 1:1 匹配 Pencil PaymentMethodModal
|
||||
class _PaymentMethodSheet extends StatefulWidget {
|
||||
const _PaymentMethodSheet({required this.methods});
|
||||
|
||||
final List<PaymentMethodItem> 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) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select payment method'),
|
||||
content: SingleChildScrollView(
|
||||
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,
|
||||
children: methods
|
||||
.map(
|
||||
(m) => ListTile(
|
||||
title: Text(m.displayName),
|
||||
subtitle: m.subPaymentMethod != null && m.subPaymentMethod!.isNotEmpty
|
||||
? Text(m.subPaymentMethod!, style: AppTypography.caption)
|
||||
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,
|
||||
trailing: m.recommend
|
||||
? Text('Recommended', style: AppTypography.caption.copyWith(color: AppColors.primary))
|
||||
: null,
|
||||
onTap: () => Navigator.of(context).pop(m),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Pay',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条支付方式项 - 图标、名称、Recommended(deny取反)、单选
|
||||
class _PaymentMethodItem extends StatelessWidget {
|
||||
const _PaymentMethodItem({
|
||||
required this.item,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PaymentMethodItem item;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付方式图标 - 优先接口 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user