feat: iap支付方式

This commit is contained in:
speakeloudest 2025-12-16 01:37:59 -08:00
parent f888b772c2
commit 60de644637
11 changed files with 368 additions and 52 deletions

View File

@ -1,5 +1,5 @@
class KRPurchaseOrderNo {
final String orderNo;
final String orderNo;
KRPurchaseOrderNo({required this.orderNo});
@ -8,8 +8,6 @@ class KRPurchaseOrderNo {
}
}
class KRPurchaseOrderUrl {
final String url;
@ -22,14 +20,16 @@ class KRPurchaseOrderUrl {
/// Checkout Tauri
class KRCheckoutResponse {
final String type; // "url" | "qr" | "stripe"
final String type; // "url" | "qr" | "stripe" | "apple_iap"
final String? checkoutUrl;
final KRStripePayment? stripe;
final KRIpaPayment? ipa;
KRCheckoutResponse({
required this.type,
this.checkoutUrl,
this.stripe,
this.ipa,
});
factory KRCheckoutResponse.fromJson(Map<String, dynamic> json) {
@ -39,6 +39,7 @@ class KRCheckoutResponse {
stripe: json['stripe'] != null
? KRStripePayment.fromJson(json['stripe'])
: null,
ipa: json['ipa'] != null ? KRIpaPayment.fromJson(json['ipa']) : null,
);
}
}
@ -62,4 +63,25 @@ class KRStripePayment {
publishableKey: json['publishable_key'] ?? '',
);
}
}
}
class KRIpaPayment {
final String productId;
final String? applicationUsername;
final String? orderNo;
KRIpaPayment({
required this.productId,
this.applicationUsername,
this.orderNo,
});
factory KRIpaPayment.fromJson(Map<String, dynamic> json) {
return KRIpaPayment(
productId: json['product_id'] ?? json['productId'] ?? '',
applicationUsername:
json['application_username'] ?? json['applicationUsername'],
orderNo: json['order_no'] ?? json['orderNo'],
);
}
}

View File

@ -78,7 +78,7 @@ class KRUserAvailableSubscribeList {
});
factory KRUserAvailableSubscribeList.fromJson(Map<String, dynamic> json) {
KRLogUtil.kr_i('订阅json列表: ${json}', tag: 'KRUserAvailableSubscribeList');
// KRLogUtil.kr_i('订阅json列表: ${json}', tag: 'KRUserAvailableSubscribeList');
final List<dynamic> listData = (json['list'] as List<dynamic>?) ?? const [];
return KRUserAvailableSubscribeList(
list: listData

View File

@ -86,7 +86,7 @@ class _KRHomeViewState extends State<KRHomeView> {
'Hi快VPN-网在我在,网快我快',
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
@ -102,7 +102,7 @@ class _KRHomeViewState extends State<KRHomeView> {
// --- ---
final normalStyle = TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.2,
);

View File

@ -18,6 +18,9 @@ import '../../../utils/kr_event_bus.dart';
import '../../../network/http_util.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'dart:async';
import 'package:kaer_with_panels/app/model/response/kr_purchase_order_no.dart';
///
///
@ -53,7 +56,8 @@ class KRPurchaseMembershipController extends GetxController {
@override
void onInit() async {
super.onInit();
print('💳 [PurchaseMembership] ========== Controller.onInit 被调用 ==========');
print(
'💳 [PurchaseMembership] ========== Controller.onInit 被调用 ==========');
print('💳 [PurchaseMembership] 当前时间: ${DateTime.now()}');
// 🔧 Controller被初始化
@ -61,11 +65,14 @@ class KRPurchaseMembershipController extends GetxController {
final dir = await getApplicationDocumentsDirectory();
final debugFile = File('${dir.path}/PURCHASE_CONTROLLER_DEBUG.txt');
await debugFile.writeAsString(
'=' * 60 + '\n'
'💳 PurchaseMembershipController.onInit 被调用!\n'
'时间: ${DateTime.now()}\n'
'版本标识: Android15_Fix_v6_Final\n'
'=' * 60 + '\n',
'=' * 60 +
'\n'
'💳 PurchaseMembershipController.onInit 被调用!\n'
'时间: ${DateTime.now()}\n'
'版本标识: Android15_Fix_v6_Final\n'
'=' *
60 +
'\n',
mode: FileMode.append,
);
print('💳 [PurchaseMembership] ✅ 调试日志已写入文件: ${debugFile.path}');
@ -178,7 +185,8 @@ class KRPurchaseMembershipController extends GetxController {
KRLogUtil.kr_w('获取支付方式超时', tag: 'PurchaseMembership');
},
);
print('💳 [PurchaseMembership] ✓ 步骤4完成支付方式数量: ${kr_paymentMethods.length}');
print(
'💳 [PurchaseMembership] ✓ 步骤4完成支付方式数量: ${kr_paymentMethods.length}');
//
kr_showPlanSelector.value = kr_plans.length > 1;
@ -193,7 +201,8 @@ class KRPurchaseMembershipController extends GetxController {
bool domainSwitched = await KRDomain.kr_switchToNextDomain();
if (domainSwitched) {
print('💳 [PurchaseMembership] ✓ 域名切换成功,当前域名: ${KRDomain.kr_currentDomain}');
print(
'💳 [PurchaseMembership] ✓ 域名切换成功,当前域名: ${KRDomain.kr_currentDomain}');
print('💳 [PurchaseMembership] 🔄 使用新域名重试...');
// HttpUtil baseUrl
@ -300,7 +309,8 @@ class KRPurchaseMembershipController extends GetxController {
final either = await _kr_subscribeApi.kr_getPublicPaymentMethods();
either.fold(
(error) {
KRLogUtil.kr_e('获取公开支付方式失败: ${error.msg}', tag: 'PurchaseMembershipController');
KRLogUtil.kr_e('获取公开支付方式失败: ${error.msg}',
tag: 'PurchaseMembershipController');
},
(responseData) {
KRLogUtil.kr_i('✅ 获取公开支付方式成功', tag: 'PurchaseMembershipController');
@ -310,14 +320,14 @@ class KRPurchaseMembershipController extends GetxController {
final paymentMethodsData = KRPaymentMethods.fromJson(responseData);
kr_paymentMethods.value = paymentMethodsData.list;
KRLogUtil.kr_i('📊 支付方式数据已加载,共 ${kr_paymentMethods.length}', tag: 'PurchaseMembershipController');
KRLogUtil.kr_i('📊 支付方式数据已加载,共 ${kr_paymentMethods.length}',
tag: 'PurchaseMembershipController');
//
for (var method in kr_paymentMethods) {
KRLogUtil.kr_i(
'💳 支付方式: ${method.name} (ID: ${method.id}, Platform: ${method.platform})',
tag: 'PurchaseMembershipController'
);
'💳 支付方式: ${method.name} (ID: ${method.id}, Platform: ${method.platform})',
tag: 'PurchaseMembershipController');
}
print('═══════════════════════════════════════');
@ -479,7 +489,7 @@ class KRPurchaseMembershipController extends GetxController {
if (isIOS) {
// 2. iOS 'stripe'
final stripeMethod = kr_paymentMethods.firstWhere(
(method) => method.platform == 'Stripe',
(method) => method.platform == 'apple_iap',
);
if (stripeMethod != null) {
@ -516,11 +526,12 @@ class KRPurchaseMembershipController extends GetxController {
.any((subscribe) => subscribe.subscribeId == selectedPlan.kr_id);
final subscribeId = isRenewal
? _kr_alreadySubscribe
.firstWhere(
(subscribe) => subscribe.subscribeId == selectedPlan.kr_id,
orElse: () => KRAlreadySubscribe(userSubscribeId: 0, subscribeId: 0), //
)
.userSubscribeId
.firstWhere(
(subscribe) => subscribe.subscribeId == selectedPlan.kr_id,
orElse: () =>
KRAlreadySubscribe(userSubscribeId: 0, subscribeId: 0), //
)
.userSubscribeId
: 0;
//
@ -539,7 +550,7 @@ class KRPurchaseMembershipController extends GetxController {
);
purchaseEither.fold(
(error) {
(error) {
print('❌ 请求失败:');
print(' 错误码: ${error.code}');
print(' 错误信息: ${error.msg}');
@ -579,7 +590,8 @@ class KRPurchaseMembershipController extends GetxController {
//
final discount = plan.kr_discount[discountIndex];
// discount 0100%
final discountRate = discount.kr_discount == 0 ? 100.0 : discount.kr_discount.toDouble();
final discountRate =
discount.kr_discount == 0 ? 100.0 : discount.kr_discount.toDouble();
return (plan.kr_unitPrice / 100) *
discount.kr_quantity *
(discountRate / 100);
@ -594,8 +606,7 @@ class KRPurchaseMembershipController extends GetxController {
discountIndex < plan.kr_discount.length) {
//
final discount = plan.kr_discount[discountIndex];
return (plan.kr_unitPrice / 100) *
(discount.kr_discount / 100);
return (plan.kr_unitPrice / 100) * (discount.kr_discount / 100);
}
return plan.kr_unitPrice / 100;
}
@ -776,20 +787,21 @@ class KRPurchaseMembershipController extends GetxController {
final checkoutEither = await _kr_subscribeApi.kr_checkout(orderNo);
checkoutEither.fold(
(error) {
(error) {
print('❌ Checkout 失败:');
print(' 错误码: ${error.code}');
print(' 错误信息: ${error.msg}');
print('═══════════════════════════════════════');
KRCommonUtil.kr_showToast(error.msg);
},
(checkoutResponse) async {
(checkoutResponse) async {
print('✅ Checkout 成功:');
print(' 支付类型: ${checkoutResponse.type}');
if (checkoutResponse.type == 'url') {
// URL
if (checkoutResponse.checkoutUrl != null && checkoutResponse.checkoutUrl!.isNotEmpty) {
if (checkoutResponse.checkoutUrl != null &&
checkoutResponse.checkoutUrl!.isNotEmpty) {
print(' 支付链接: ${checkoutResponse.checkoutUrl}');
print('🌐 正在用外部浏览器打开支付链接...');
print('═══════════════════════════════════════');
@ -839,7 +851,8 @@ class KRPurchaseMembershipController extends GetxController {
} else if (checkoutResponse.type == 'stripe') {
// Stripe Stripe
print(' Stripe Method: ${checkoutResponse.stripe?.method}');
print(' Stripe Client Secret: ${checkoutResponse.stripe?.clientSecret}');
print(
' Stripe Client Secret: ${checkoutResponse.stripe?.clientSecret}');
print('💳 显示 Stripe 支付表单...');
print('═══════════════════════════════════════');
@ -854,7 +867,8 @@ class KRPurchaseMembershipController extends GetxController {
KRCommonUtil.kr_showToast('Apple Pay 仅在 iOS 可用');
return;
}
print('Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}');
print(
'Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}');
print('Merchant identifier: ${Stripe.merchantIdentifier}');
// Platform PayApple Pay
final supported = await Stripe.instance.isPlatformPaySupported();
@ -870,10 +884,9 @@ class KRPurchaseMembershipController extends GetxController {
cartItems: [
ApplePayCartSummaryItem.immediate(
label: 'Hi快VPN 服务',
amount: kr_getPlanPrice(
kr_plans[kr_selectedPlanIndex.value],
discountIndex: kr_selectedDiscountIndex.value
).toStringAsFixed(2),
amount: kr_getPlanPrice(kr_plans[kr_selectedPlanIndex.value],
discountIndex: kr_selectedDiscountIndex.value)
.toStringAsFixed(2),
),
],
),
@ -897,10 +910,11 @@ class KRPurchaseMembershipController extends GetxController {
'stripe': checkoutResponse.stripe,
},
);
} else if (checkoutResponse.type == 'balance') {
} else if (checkoutResponse.type == 'balance') {
// Stripe Stripe
print(' Stripe Method: ${checkoutResponse.stripe?.method}');
print(' Stripe Client Secret: ${checkoutResponse.stripe?.clientSecret}');
print(
' Stripe Client Secret: ${checkoutResponse.stripe?.clientSecret}');
print('💳 显示 Stripe 支付表单...');
print('═══════════════════════════════════════');
@ -913,13 +927,242 @@ class KRPurchaseMembershipController extends GetxController {
'checkout_type': 'balance',
},
);
} else if (checkoutResponse.type == 'apple_iap') {
if (!Platform.isIOS) {
KRCommonUtil.kr_showToast('仅限在 iOS 设备上购买');
return;
}
final KRIpaPayment iapParams = (checkoutResponse.ipa == null ||
(checkoutResponse.ipa?.productId.isEmpty ?? true))
? KRIpaPayment(
productId: 'com.hifastvpn.vip.day${kr_getSelectedQuantity()}')
: checkoutResponse.ipa!;
final iap = InAppPurchase.instance;
final available = await iap.isAvailable();
if (!available) {
print('IAP 不可用');
KRCommonUtil.kr_showToast('App Store 不可用');
return;
}
final productIds = {iapParams.productId};
final productDetailsResponse =
await iap.queryProductDetails(productIds);
if (productDetailsResponse.error != null) {
final e = productDetailsResponse.error!;
print('IAP 查询商品错误 code: ${e.code}');
print('IAP 查询商品错误 message: ${e.message}');
print('IAP 查询商品错误 iapParams.productId: ${iapParams.productId}');
KRCommonUtil.kr_showToast('无法获取商品信息: ${e.message}');
return;
}
if (productDetailsResponse.productDetails.isEmpty) {
print('IAP 商品列表为空 productId: ${iapParams.productId}');
KRCommonUtil.kr_showToast('未找到对应商品');
return;
}
final product = productDetailsResponse.productDetails.first;
final purchaseParam = PurchaseParam(
productDetails: product,
applicationUserName: iapParams.applicationUsername,
);
// ----------------------------------------------------------------------
// ProductDetails purchaseParam
// ----------------------------------------------------------------------
late StreamSubscription<List<PurchaseDetails>> subscription;
// 1 Stream
print('--- IAP 流程:购买 Stream 监听即将启动 ---');
subscription = iap.purchaseStream.listen(
(purchases) async {
print('IAP Stream 收到 ${purchases.length} 个交易事件');
for (final p in purchases) {
if (p.productID != iapParams.productId) continue;
final status = p.status.toString().split('.').last;
print('处理交易产品ID ${p.productID}, 状态: $status');
// =======================
// 1 Pending
// =======================
if (p.status == PurchaseStatus.pending) {
print('交易状态Pending等待 App Store...');
continue;
}
// =======================
// 2 Error
// =======================
if (p.status == PurchaseStatus.error) {
print('交易失败');
if (p.error != null) {
print('IAP 错误 code: ${p.error!.code}');
print('IAP 错误 message: ${p.error!.message}');
}
// complete
if (p.pendingCompletePurchase) {
await iap.completePurchase(p);
print('Error 状态交易已 complete');
}
KRCommonUtil.kr_showToast('购买失败');
continue;
}
// =======================
// 3 Purchased / Restored
// =======================
if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) {
// -----------------------
//
// -----------------------
final receiptData = p.verificationData.serverVerificationData;
final transactionId = p.purchaseID;
final productID = p.productID;
print('***************** IAP 交易凭证数据 *****************');
print('产品 ID (ProductID): $productID');
print('交易 ID (PurchaseID): $transactionId');
print('Receipt(Base64):');
print(receiptData);
print('**************************************************');
// -----------------------
// attach
// -----------------------
final attachEither =
await _kr_subscribeApi.kr_attachAppleIapTransaction(
transactionId!,
orderNo,
);
bool attachSuccess = false;
attachEither.fold(
(error) {
print('IAP attach失败 code: ${error.code}');
print('IAP attach失败 msg: ${error.msg}');
},
(ok) {
attachSuccess = true;
print('IAP attach成功: $ok');
},
);
// attach complete Apple
if (!attachSuccess) {
print('attach 失败,暂不 complete等待重试');
continue;
}
// -----------------------
// attach complete
// -----------------------
if (p.pendingCompletePurchase) {
await iap.completePurchase(p);
print('交易已 completePurchase');
}
// -----------------------
//
// -----------------------
Get.toNamed(
Routes.KR_ORDER_STATUS,
arguments: {
'order': orderNo,
'payment_type': paymentPlatform,
'checkout_type': 'ipa',
},
);
// cancel subscription
// Stream
}
}
},
onError: (error) {
print('--- IAP Stream 监听发生错误: $error ---');
},
onDone: () {
print('--- IAP Stream 已关闭 ---');
},
);
// 🔴 buy
await _cleanPendingPurchase(iap, iapParams.productId);
print('--- IAP 流程:即将调用 buyNonConsumable ---');
try {
final result =
await iap.buyNonConsumable(purchaseParam: purchaseParam);
print('--- IAP 流程buyNonConsumable 调用返回。返回值:$result ---');
if (!result) {
// false
print('IAP 购买请求本地失败:请检查 PurchaseParam 或设备设置。');
KRCommonUtil.kr_showToast('购买请求失败,请重试');
// Stream error
}
} catch (e) {
// Stream
print('--- IAP 流程buyNonConsumable 调用发生异常:$e ---');
subscription.cancel();
KRCommonUtil.kr_showToast('购买服务异常,请重试');
}
} else {
print('⚠️ 未知的支付类型: ${checkoutResponse.type}');
print('═══════════════════════════════════════');
// KRCommonUtil.kr_showToast('不支持的支付类型: ${checkoutResponse.type}');
}
},
);
}
/// 🔴 pending
Future<void> _cleanPendingPurchase(
InAppPurchase iap,
String productId,
) async {
print('🔧 IAP 清理 pending 交易开始');
final completer = Completer<void>();
late StreamSubscription<List<PurchaseDetails>> tempSub;
tempSub = iap.purchaseStream.listen((purchases) async {
for (final p in purchases) {
if (p.productID != productId) continue;
if (p.pendingCompletePurchase) {
print('🔧 发现 pending 交易completePurchase');
await iap.completePurchase(p);
}
}
// 👇
await tempSub.cancel();
completer.complete();
});
// StoreKit
await Future.delayed(const Duration(milliseconds: 800));
if (!completer.isCompleted) {
await tempSub.cancel();
completer.complete();
}
print('🔧 IAP 清理 pending 交易完成');
}
}

View File

@ -30,8 +30,8 @@ class BaseResponse<T> {
if (cipherText.isNotEmpty && nonce.isNotEmpty) {
try {
if (kDebugMode) {
print('═══════════════════════════════════════');
print('🔐 检测到加密响应,开始解密...');
// print('═══════════════════════════════════════');
// print('🔐 检测到加密响应,开始解密...');
print('📥 加密数据长度: ${cipherText.length} 字符');
print('⏰ 时间戳: $nonce');
}

View File

@ -431,9 +431,6 @@ class _KRSimpleHttpInterceptor extends Interceptor {
if (kDebugMode) {
print('');
}
if (kDebugMode) {
print('🔐 检测到加密请求,正在解密...');
}
//
final encryptedData = data['data'] as String;
final nonce = data['time'] as String;

View File

@ -111,6 +111,8 @@ abstract class Api {
///
static const String kr_getPublicPaymentMethods = "/v1/public/payment/methods";
static const String kr_attachAppleIapTransaction = "/v1/public/iap/apple/transactions/attach_by_id";
///
static const String kr_getUserInfo = "/v1/public/user/info";

View File

@ -329,4 +329,23 @@ class KRSubscribeApi {
return right(baseResponse.model);
}
Future<Either<HttpError, bool>> kr_attachAppleIapTransaction(String transactionId, String orderNo) async {
final Map<String, dynamic> data = <String, dynamic>{};
data['transaction_id'] = transactionId;
data['order_no'] = orderNo;
BaseResponse<KRStatus> baseResponse =
await HttpUtil.getInstance().request<KRStatus>(
Api.kr_attachAppleIapTransaction,
data,
method: HttpMethod.POST,
isShowLoading: true,
);
if (!baseResponse.isSuccess) {
return left(HttpError(msg: baseResponse.retMsg, code: baseResponse.retCode));
}
return right(baseResponse.model.kr_bl);
}
}

View File

@ -250,15 +250,15 @@ class KRSubscribeService {
}
kr_currentSubscribe.value = updatedSubscribe;
KRLogUtil.kr_i('更新当前订阅信息', tag: 'SubscribeService');
// KRLogUtil.kr_i('更新当前订阅信息', tag: 'SubscribeService');
//
kr_availableSubscribes.assignAll(subscribes);
}
}
KRLogUtil.kr_i('获取可用订阅列表成功: ${subscribes.length} 个订阅',
tag: 'SubscribeService');
// KRLogUtil.kr_i('获取可用订阅列表成功: ${subscribes.length} 个订阅',
// tag: 'SubscribeService');
KRLogUtil.kr_i(
'订阅列表: ${subscribes.map((s) => '${s.name}(${s.id})').join(', ')}',
tag: 'SubscribeService');

View File

@ -861,6 +861,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
in_app_purchase:
dependency: "direct main"
description:
name: in_app_purchase
sha256: "11a40f148eeb4f681a0572003e2b33432e110c90c1bbb4f9ef83b81ec0c4f737"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
in_app_purchase_android:
dependency: transitive
description:
name: in_app_purchase_android
sha256: "45ae4fe253f85b4fcc58b421fe137f6e48aca16bf8a618cd760cb0542e7f854e"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
in_app_purchase_platform_interface:
dependency: transitive
description:
name: in_app_purchase_platform_interface
sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
in_app_purchase_storekit:
dependency: transitive
description:
name: in_app_purchase_storekit
sha256: "6ce1361278cacc0481508989ba419b2c9f46a2b0dc54b3fe54f5ee63c2718fef"
url: "https://pub.dev"
source: hosted
version: "0.3.22+1"
intl:
dependency: transitive
description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.0.4+107
version: 0.0.4+108
environment:
sdk: ">=3.5.0 <4.0.0"
@ -113,6 +113,7 @@ dependencies:
tray_manager: ^0.2.0
device_info_plus: ^11.3.0
flutter_stripe: ^10.1.0
in_app_purchase: 3.2.1
dev_dependencies:
flutter_test:
sdk: flutter