|
|
@ -3,6 +3,9 @@ import 'dart:convert'; |
|
|
import 'dart:async'; |
|
|
import 'dart:async'; |
|
|
import 'package:flutter/foundation.dart'; |
|
|
import 'package:flutter/foundation.dart'; |
|
|
import 'package:in_app_purchase/in_app_purchase.dart'; |
|
|
import 'package:in_app_purchase/in_app_purchase.dart'; |
|
|
|
|
|
import 'package:in_app_purchase_android/in_app_purchase_android.dart'; |
|
|
|
|
|
import 'package:package_info_plus/package_info_plus.dart'; |
|
|
|
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
|
import '../../data/services/network/api.dart'; |
|
|
import '../../data/services/network/api.dart'; |
|
|
import 'package:tobias/tobias.dart'; |
|
|
import 'package:tobias/tobias.dart'; |
|
|
import 'package:url_launcher/url_launcher.dart'; |
|
|
import 'package:url_launcher/url_launcher.dart'; |
|
|
@ -22,6 +25,9 @@ class PayService extends GetxService { |
|
|
StreamSubscription<List<PurchaseDetails>>? _iapSubscription; |
|
|
StreamSubscription<List<PurchaseDetails>>? _iapSubscription; |
|
|
final RxBool iapAvailable = false.obs; |
|
|
final RxBool iapAvailable = false.obs; |
|
|
PaymentResultCallback? _iapOnResult; |
|
|
PaymentResultCallback? _iapOnResult; |
|
|
|
|
|
// 最近一次 IAP 购买上下文,用于主动查询时携带验证参数 |
|
|
|
|
|
PurchaseDetails? _lastIapPurchase; |
|
|
|
|
|
ProductDetails? _lastIapProduct; |
|
|
|
|
|
|
|
|
String? get lastOrderId => _lastOrderId; |
|
|
String? get lastOrderId => _lastOrderId; |
|
|
|
|
|
|
|
|
@ -169,11 +175,35 @@ class PayService extends GetxService { |
|
|
return false; |
|
|
return false; |
|
|
} |
|
|
} |
|
|
try { |
|
|
try { |
|
|
final purchaseParam = PurchaseParam(productDetails: product); |
|
|
// 记录最近一次用于购买的商品详情,便于后续构建查询参数 |
|
|
final ok = await _inAppPurchase.buyConsumable( |
|
|
_lastIapProduct = product; |
|
|
purchaseParam: purchaseParam, |
|
|
final isAndroid = |
|
|
autoConsume: true, |
|
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android; |
|
|
); |
|
|
bool ok; |
|
|
|
|
|
if (isAndroid && product is GooglePlayProductDetails) { |
|
|
|
|
|
// 通过环境变量决定订阅或一次性商品路径,避免访问不兼容的属性 |
|
|
|
|
|
final isSubscription = |
|
|
|
|
|
_getEnvInt('APP_IAP_GOOGLE_IS_SUBSCRIPTION', defaultValue: 0) == 1; |
|
|
|
|
|
if (isSubscription) { |
|
|
|
|
|
print('[IAP-android] 订阅商品(ENV),使用 buyNonConsumable'); |
|
|
|
|
|
final gpParam = GooglePlayPurchaseParam(productDetails: product); |
|
|
|
|
|
ok = await _inAppPurchase.buyNonConsumable(purchaseParam: gpParam); |
|
|
|
|
|
} else { |
|
|
|
|
|
// 普通一次性/消耗型商品,使用 buyConsumable |
|
|
|
|
|
final purchaseParam = PurchaseParam(productDetails: product); |
|
|
|
|
|
ok = await _inAppPurchase.buyConsumable( |
|
|
|
|
|
purchaseParam: purchaseParam, |
|
|
|
|
|
autoConsume: true, |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
} else { |
|
|
|
|
|
// iOS 或非订阅商品,按消耗型处理(如需支持 iOS 订阅,可通过 ENV 切换调用 buyNonConsumable) |
|
|
|
|
|
final purchaseParam = PurchaseParam(productDetails: product); |
|
|
|
|
|
ok = await _inAppPurchase.buyConsumable( |
|
|
|
|
|
purchaseParam: purchaseParam, |
|
|
|
|
|
autoConsume: true, |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
if (!ok) { |
|
|
if (!ok) { |
|
|
onResult?.call(false, '无法发起内购'); |
|
|
onResult?.call(false, '无法发起内购'); |
|
|
} else { |
|
|
} else { |
|
|
@ -188,11 +218,17 @@ class PayService extends GetxService { |
|
|
|
|
|
|
|
|
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> detailsList) async { |
|
|
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> detailsList) async { |
|
|
for (final details in detailsList) { |
|
|
for (final details in detailsList) { |
|
|
|
|
|
// 记录最近一次 IAP 购买详情 |
|
|
|
|
|
_lastIapPurchase = details; |
|
|
|
|
|
print( |
|
|
|
|
|
'[IAP] 更新: status=${details.status} product=${details.productID} purchaseID=${details.purchaseID} error=${details.error}'); |
|
|
switch (details.status) { |
|
|
switch (details.status) { |
|
|
case PurchaseStatus.pending: |
|
|
case PurchaseStatus.pending: |
|
|
_iapOnResult?.call(true, '支付处理中…'); |
|
|
_iapOnResult?.call(true, '支付处理中…'); |
|
|
break; |
|
|
break; |
|
|
case PurchaseStatus.error: |
|
|
case PurchaseStatus.error: |
|
|
|
|
|
print( |
|
|
|
|
|
'[IAP] 错误: code=${details.error?.code} message=${details.error?.message}'); |
|
|
_iapOnResult?.call(false, details.error?.message ?? '支付错误'); |
|
|
_iapOnResult?.call(false, details.error?.message ?? '支付错误'); |
|
|
break; |
|
|
break; |
|
|
case PurchaseStatus.purchased: |
|
|
case PurchaseStatus.purchased: |
|
|
@ -250,6 +286,66 @@ class PayService extends GetxService { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 构建 Apple/Google 主动查询(pay_askorder)所需的验证参数 |
|
|
|
|
|
/// 返回与后端 PayAskOrCancelReq 对应的 Map |
|
|
|
|
|
Future<Map<String, dynamic>> buildIapAskParams() async { |
|
|
|
|
|
final params = <String, dynamic>{}; |
|
|
|
|
|
if (_lastOrderId != null && _lastOrderId!.isNotEmpty) { |
|
|
|
|
|
params['order_id'] = _lastOrderId; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
final platform = _platformString(); |
|
|
|
|
|
if (platform == 'ios') { |
|
|
|
|
|
final receiptOrData = |
|
|
|
|
|
_lastIapPurchase?.verificationData.serverVerificationData ?? ''; |
|
|
|
|
|
final txId = _lastIapPurchase?.purchaseID ?? ''; |
|
|
|
|
|
// 是否按交易ID校验,默认 false,可通过环境变量开启 |
|
|
|
|
|
final useTxIdEnv = _getEnvBool('APP_IAP_USE_TXID', defaultValue: false); |
|
|
|
|
|
// 沙箱标识,默认在非发布模式下认为是沙箱;支持环境变量覆盖 |
|
|
|
|
|
final sandbox = |
|
|
|
|
|
_getEnvBool('APP_IAP_SANDBOX', defaultValue: !kReleaseMode); |
|
|
|
|
|
params['apple_receipt_data'] = useTxIdEnv ? txId : receiptOrData; |
|
|
|
|
|
params['apple_use_transaction_id'] = useTxIdEnv; |
|
|
|
|
|
params['apple_sandbox'] = sandbox; |
|
|
|
|
|
} else if (platform == 'android') { |
|
|
|
|
|
final packageInfo = await PackageInfo.fromPlatform(); |
|
|
|
|
|
final pkg = packageInfo.packageName; |
|
|
|
|
|
final token = |
|
|
|
|
|
_lastIapPurchase?.verificationData.serverVerificationData ?? ''; |
|
|
|
|
|
final productId = |
|
|
|
|
|
_lastIapPurchase?.productID ?? _lastIapProduct?.id ?? ''; |
|
|
|
|
|
// 订阅标识,默认 false;如需严格区分,可在商品元数据或环境变量中配置 |
|
|
|
|
|
final isSub = |
|
|
|
|
|
_getEnvBool('APP_IAP_GOOGLE_IS_SUBSCRIPTION', defaultValue: false); |
|
|
|
|
|
params['google_package_name'] = pkg; |
|
|
|
|
|
params['google_product_id'] = productId; |
|
|
|
|
|
params['google_purchase_token'] = token; |
|
|
|
|
|
params['google_is_subscription'] = isSub; |
|
|
|
|
|
} |
|
|
|
|
|
return params; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
bool _getEnvBool(String key, {required bool defaultValue}) { |
|
|
|
|
|
try { |
|
|
|
|
|
final v = dotenv.env[key]; |
|
|
|
|
|
if (v != null && v.isNotEmpty) { |
|
|
|
|
|
final low = v.toLowerCase(); |
|
|
|
|
|
return low == 'true' || low == '1' || low == 'yes'; |
|
|
|
|
|
} |
|
|
|
|
|
} catch (_) {} |
|
|
|
|
|
return defaultValue; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
int _getEnvInt(String key, {required int defaultValue}) { |
|
|
|
|
|
try { |
|
|
|
|
|
final v = dotenv.env[key]; |
|
|
|
|
|
if (v != null && v.isNotEmpty) { |
|
|
|
|
|
return int.tryParse(v.trim()) ?? defaultValue; |
|
|
|
|
|
} |
|
|
|
|
|
} catch (_) {} |
|
|
|
|
|
return defaultValue; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
String _platformString() { |
|
|
String _platformString() { |
|
|
if (kIsWeb) return 'web'; |
|
|
if (kIsWeb) return 'web'; |
|
|
switch (defaultTargetPlatform) { |
|
|
switch (defaultTargetPlatform) { |
|
|
@ -397,7 +493,7 @@ class PayService extends GetxService { |
|
|
'product_name': productName, |
|
|
'product_name': productName, |
|
|
'product_desc': productDesc ?? '', |
|
|
'product_desc': productDesc ?? '', |
|
|
'amount': amount, |
|
|
'amount': amount, |
|
|
'payment_method': paymentMethod, |
|
|
'payment_method': 4, |
|
|
'client_ip': clientIp ?? '', |
|
|
'client_ip': clientIp ?? '', |
|
|
'notify_url': notifyUrl ?? '', |
|
|
'notify_url': notifyUrl ?? '', |
|
|
'extra_data': extraData ?? '', |
|
|
'extra_data': extraData ?? '', |
|
|
@ -517,7 +613,6 @@ class PayService extends GetxService { |
|
|
productName: description, |
|
|
productName: description, |
|
|
productDesc: description, |
|
|
productDesc: description, |
|
|
amount: amount, |
|
|
amount: amount, |
|
|
paymentMethod: 3, // 支付宝支付 |
|
|
|
|
|
notifyUrl: 'https://api.ideapsound.com/api/home/pay_alipaynotify', |
|
|
notifyUrl: 'https://api.ideapsound.com/api/home/pay_alipaynotify', |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
@ -612,7 +707,6 @@ class PayService extends GetxService { |
|
|
required String productName, |
|
|
required String productName, |
|
|
String? productDesc, |
|
|
String? productDesc, |
|
|
required int amount, |
|
|
required int amount, |
|
|
int paymentMethod = 3, // 2表示支付宝 |
|
|
|
|
|
String? clientIp, |
|
|
String? clientIp, |
|
|
String? notifyUrl, |
|
|
String? notifyUrl, |
|
|
String? extraData, |
|
|
String? extraData, |
|
|
@ -624,7 +718,7 @@ class PayService extends GetxService { |
|
|
'product_name': productName, |
|
|
'product_name': productName, |
|
|
'product_desc': productDesc ?? '', |
|
|
'product_desc': productDesc ?? '', |
|
|
'amount': amount, |
|
|
'amount': amount, |
|
|
'payment_method': paymentMethod, |
|
|
'payment_method': 3, |
|
|
'client_ip': clientIp ?? '', |
|
|
'client_ip': clientIp ?? '', |
|
|
'notify_url': notifyUrl ?? '', |
|
|
'notify_url': notifyUrl ?? '', |
|
|
'extra_data': extraData ?? '', |
|
|
'extra_data': extraData ?? '', |
|
|
@ -679,7 +773,6 @@ class PayService extends GetxService { |
|
|
required String productName, |
|
|
required String productName, |
|
|
String? productDesc, |
|
|
String? productDesc, |
|
|
required int amount, |
|
|
required int amount, |
|
|
int paymentMethod = 5, // 约定:5 表示 PayPal |
|
|
|
|
|
String? clientIp, |
|
|
String? clientIp, |
|
|
String? notifyUrl, |
|
|
String? notifyUrl, |
|
|
String? extraData, |
|
|
String? extraData, |
|
|
@ -690,7 +783,7 @@ class PayService extends GetxService { |
|
|
'product_name': productName, |
|
|
'product_name': productName, |
|
|
'product_desc': productDesc ?? '', |
|
|
'product_desc': productDesc ?? '', |
|
|
'amount': amount, |
|
|
'amount': amount, |
|
|
'payment_method': paymentMethod, |
|
|
'payment_method': 5, |
|
|
'client_ip': clientIp ?? '', |
|
|
'client_ip': clientIp ?? '', |
|
|
'notify_url': notifyUrl ?? '', |
|
|
'notify_url': notifyUrl ?? '', |
|
|
'extra_data': extraData ?? '', |
|
|
'extra_data': extraData ?? '', |
|
|
@ -828,7 +921,6 @@ class PayService extends GetxService { |
|
|
productName: description, |
|
|
productName: description, |
|
|
productDesc: description, |
|
|
productDesc: description, |
|
|
amount: amount, |
|
|
amount: amount, |
|
|
paymentMethod: 5, |
|
|
|
|
|
notifyUrl: 'https://api.ideapsound.com/api/home/pay_paypalnotify', |
|
|
notifyUrl: 'https://api.ideapsound.com/api/home/pay_paypalnotify', |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
@ -843,4 +935,106 @@ class PayService extends GetxService { |
|
|
return false; |
|
|
return false; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 创建 Apple 内购订单,仅用于在服务端生成并记录订单号,后续回查使用 |
|
|
|
|
|
/// 返回服务端生成的 `order_id`,若失败则返回 null |
|
|
|
|
|
Future<String?> createAppleIapOrder({ |
|
|
|
|
|
required String productId, |
|
|
|
|
|
required String productName, |
|
|
|
|
|
String? productDesc, |
|
|
|
|
|
required int amount, |
|
|
|
|
|
}) async { |
|
|
|
|
|
try { |
|
|
|
|
|
final resp = await Api.paycreateorder({ |
|
|
|
|
|
'product_id': productId, |
|
|
|
|
|
'product_name': productName, |
|
|
|
|
|
'product_desc': productDesc ?? '', |
|
|
|
|
|
'amount': amount, |
|
|
|
|
|
'payment_method': 6, |
|
|
|
|
|
}); |
|
|
|
|
|
if (resp != null) { |
|
|
|
|
|
String? orderId; |
|
|
|
|
|
const candidates = [ |
|
|
|
|
|
'order_id', |
|
|
|
|
|
'orderId', |
|
|
|
|
|
'out_trade_no', |
|
|
|
|
|
'outTradeNo' |
|
|
|
|
|
]; |
|
|
|
|
|
for (final k in candidates) { |
|
|
|
|
|
final v = resp[k]; |
|
|
|
|
|
if (v is String && v.isNotEmpty) { |
|
|
|
|
|
orderId = v; |
|
|
|
|
|
break; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
if (orderId == null && resp['data'] is Map<String, dynamic>) { |
|
|
|
|
|
final data = resp['data'] as Map<String, dynamic>; |
|
|
|
|
|
for (final k in candidates) { |
|
|
|
|
|
final v = data[k]; |
|
|
|
|
|
if (v is String && v.isNotEmpty) { |
|
|
|
|
|
orderId = v; |
|
|
|
|
|
break; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
_lastOrderId = orderId; |
|
|
|
|
|
return orderId; |
|
|
|
|
|
} |
|
|
|
|
|
return null; |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
print('创建 Apple IAP 订单异常: $e'); |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// 创建 Google 内购订单,仅用于在服务端生成并记录订单号,后续回查使用 |
|
|
|
|
|
/// 返回服务端生成的 `order_id`,若失败则返回 null |
|
|
|
|
|
Future<String?> createGoogleIapOrder({ |
|
|
|
|
|
required String productId, |
|
|
|
|
|
required String productName, |
|
|
|
|
|
String? productDesc, |
|
|
|
|
|
required int amount, |
|
|
|
|
|
}) async { |
|
|
|
|
|
try { |
|
|
|
|
|
final resp = await Api.paycreateorder({ |
|
|
|
|
|
'product_id': productId, |
|
|
|
|
|
'product_name': productName, |
|
|
|
|
|
'product_desc': productDesc ?? '', |
|
|
|
|
|
'amount': amount, |
|
|
|
|
|
'payment_method': 7, |
|
|
|
|
|
}); |
|
|
|
|
|
if (resp != null) { |
|
|
|
|
|
String? orderId; |
|
|
|
|
|
const candidates = [ |
|
|
|
|
|
'order_id', |
|
|
|
|
|
'orderId', |
|
|
|
|
|
'out_trade_no', |
|
|
|
|
|
'outTradeNo' |
|
|
|
|
|
]; |
|
|
|
|
|
for (final k in candidates) { |
|
|
|
|
|
final v = resp[k]; |
|
|
|
|
|
if (v is String && v.isNotEmpty) { |
|
|
|
|
|
orderId = v; |
|
|
|
|
|
break; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
if (orderId == null && resp['data'] is Map<String, dynamic>) { |
|
|
|
|
|
final data = resp['data'] as Map<String, dynamic>; |
|
|
|
|
|
for (final k in candidates) { |
|
|
|
|
|
final v = data[k]; |
|
|
|
|
|
if (v is String && v.isNotEmpty) { |
|
|
|
|
|
orderId = v; |
|
|
|
|
|
break; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
_lastOrderId = orderId; |
|
|
|
|
|
return orderId; |
|
|
|
|
|
} |
|
|
|
|
|
return null; |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
print('创建 Google IAP 订单异常: $e'); |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|