feat: 增加flutter_keychain来缓存订单号

This commit is contained in:
speakeloudest 2025-12-17 01:46:42 -08:00
parent 60de644637
commit 743631c4ce
6 changed files with 365 additions and 291 deletions

View File

@ -21,6 +21,8 @@ import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'dart:async'; import 'dart:async';
import 'package:kaer_with_panels/app/model/response/kr_purchase_order_no.dart'; import 'package:kaer_with_panels/app/model/response/kr_purchase_order_no.dart';
import 'package:kaer_with_panels/app/widgets/dialogs/hi_dialog.dart';
import 'package:kaer_with_panels/app/services/iap/iap_pending_order_service.dart';
/// ///
/// ///
@ -53,6 +55,9 @@ class KRPurchaseMembershipController extends GetxController {
/// ///
RxInt _kr_balance = 0.obs; RxInt _kr_balance = 0.obs;
// -------------------- IAP --------------------
late StreamSubscription<List<PurchaseDetails>> _iapSubscription;
@override @override
void onInit() async { void onInit() async {
super.onInit(); super.onInit();
@ -80,11 +85,22 @@ class KRPurchaseMembershipController extends GetxController {
print('💳 [PurchaseMembership] ❌ 写入调试日志失败: $e'); print('💳 [PurchaseMembership] ❌ 写入调试日志失败: $e');
} }
// IAP
if (Platform.isIOS) {
_iapSubscription = InAppPurchase.instance.purchaseStream.listen(
_handleIapUpdates,
onError: (error) {
print('IAP Stream Error: $error');
},
);
}
kr_initializeData(); kr_initializeData();
} }
@override @override
void onClose() { void onClose() {
_iapSubscription.cancel();
_kr_eventWorker?.dispose(); _kr_eventWorker?.dispose();
super.onClose(); super.onClose();
} }
@ -445,6 +461,24 @@ class KRPurchaseMembershipController extends GetxController {
print('开始订阅流程,选定支付'); print('开始订阅流程,选定支付');
kr_errorMessage.value = ''; kr_errorMessage.value = '';
// iOS
if (Platform.isIOS) {
final existing = await IAPPendingOrderService.getPendingOrderNo();
if (existing != null && existing.isNotEmpty) {
await HIDialog.show(
title: '存在未完成订单',
message: '检测到未完成订单,需要恢复购买以完成订阅。',
confirmText: '恢复购买',
barrierDismissible: false,
preventBackDismiss: true,
onConfirm: () {
kr_restorePurchases(); //
},
);
return; //
}
}
try { try {
await kr_processPurchaseAndCheckout(); await kr_processPurchaseAndCheckout();
} catch (e) { } catch (e) {
@ -472,7 +506,7 @@ class KRPurchaseMembershipController extends GetxController {
/// ///
Future<void> kr_processPurchaseAndCheckout() async { Future<void> kr_processPurchaseAndCheckout() async {
final selectedPlan = kr_plans[kr_selectedPlanIndex.value]; final selectedPlan = kr_plans[kr_selectedPlanIndex.value];
// ========================================================================= // =========================================================================
// 🔽 iOS 使 Stripe 🔽 // 🔽 iOS 使 Stripe 🔽
// ========================================================================= // =========================================================================
final isIOS = Platform.isIOS; final isIOS = Platform.isIOS;
@ -936,7 +970,7 @@ class KRPurchaseMembershipController extends GetxController {
final KRIpaPayment iapParams = (checkoutResponse.ipa == null || final KRIpaPayment iapParams = (checkoutResponse.ipa == null ||
(checkoutResponse.ipa?.productId.isEmpty ?? true)) (checkoutResponse.ipa?.productId.isEmpty ?? true))
? KRIpaPayment( ? KRIpaPayment(
productId: 'com.hifastvpn.vip.day${kr_getSelectedQuantity()}') productId: 'com.hifastvpn.plan.day${kr_getSelectedQuantity()}')
: checkoutResponse.ipa!; : checkoutResponse.ipa!;
final iap = InAppPurchase.instance; final iap = InAppPurchase.instance;
@ -965,161 +999,27 @@ class KRPurchaseMembershipController extends GetxController {
} }
final product = productDetailsResponse.productDetails.first; final product = productDetailsResponse.productDetails.first;
final kr_userId = KRAppRunData.getInstance().kr_userId.value;
final purchaseParam = PurchaseParam( final purchaseParam = PurchaseParam(
productDetails: product, productDetails: product,
applicationUserName: iapParams.applicationUsername, applicationUserName: 'uid_${kr_userId}|orderNo_${orderNo}',
); );
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// ProductDetails purchaseParam // ProductDetails purchaseParam
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
await IAPPendingOrderService.setPendingOrderNo(orderNo);
late StreamSubscription<List<PurchaseDetails>> subscription; KRCommonUtil.kr_showLoading();
// 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 { try {
final result = final result =
await iap.buyNonConsumable(purchaseParam: purchaseParam); await iap.buyNonConsumable(purchaseParam: purchaseParam);
print('--- IAP 流程buyNonConsumable 调用返回。返回值:$result ---');
if (!result) { if (!result) {
// false KRCommonUtil.kr_hideLoading();
print('IAP 购买请求本地失败:请检查 PurchaseParam 或设备设置。');
KRCommonUtil.kr_showToast('购买请求失败,请重试'); KRCommonUtil.kr_showToast('购买请求失败,请重试');
// Stream error
} }
} catch (e) { } catch (e) {
// Stream KRCommonUtil.kr_hideLoading();
print('--- IAP 流程buyNonConsumable 调用发生异常:$e ---'); KRCommonUtil.kr_showToast('购买服务异常,请重试$e');
subscription.cancel();
KRCommonUtil.kr_showToast('购买服务异常,请重试');
} }
} else { } else {
print('⚠️ 未知的支付类型: ${checkoutResponse.type}'); print('⚠️ 未知的支付类型: ${checkoutResponse.type}');
@ -1129,40 +1029,93 @@ class KRPurchaseMembershipController extends GetxController {
}, },
); );
} }
/// 🔴 pending
Future<void> _cleanPendingPurchase(
InAppPurchase iap,
String productId,
) async {
print('🔧 IAP 清理 pending 交易开始');
final completer = Completer<void>(); // -------------------- IAP --------------------
void _handleIapUpdates(List<PurchaseDetails> purchases) async {
final iap = InAppPurchase.instance;
final pendingOrderNo = await IAPPendingOrderService.getPendingOrderNo();
print('p.pendingOrderNo ${pendingOrderNo}');
if (pendingOrderNo == null || pendingOrderNo.isEmpty) return;
late StreamSubscription<List<PurchaseDetails>> tempSub;
tempSub = iap.purchaseStream.listen((purchases) async {
for (final p in purchases) { for (final p in purchases) {
if (p.productID != productId) continue; print('p.status ${p.status}');
if (p.status == PurchaseStatus.pending) continue;
if (p.pendingCompletePurchase) { if (p.status == PurchaseStatus.error) {
print('🔧 发现 pending 交易completePurchase'); if (p.pendingCompletePurchase) await iap.completePurchase(p);
await iap.completePurchase(p); KRCommonUtil.kr_showToast('购买失败');
continue;
}
if (p.status == PurchaseStatus.purchased || p.status == PurchaseStatus.restored) {
KRCommonUtil.kr_showLoading();
bool ok = false;
final either = await _kr_subscribeApi.kr_attachAppleIapTransaction(p.purchaseID!, pendingOrderNo);
either.fold((e) {}, (v) => ok = true);
// if (p.status == PurchaseStatus.purchased) {
//
// either.fold((e) {}, (v) => ok = true);
// } else if (p.status == PurchaseStatus.restored) {
// final either = await _kr_subscribeApi.kr_restoreAppleIapTransaction(p.purchaseID!, pendingOrderNo);
// either.fold((e) {}, (v) => ok = true);
// }
//
if (!ok) {
KRCommonUtil.kr_hideLoading();
HIDialog.show(
title: '激活失败,请稍后重试',
message: '检测到未完成订单,需要恢复购买以完成订阅。',
confirmText: '恢复购买',
cancelText: '关闭',
barrierDismissible: false,
preventBackDismiss: true,
onConfirm: () {
kr_restorePurchases(); //
},
);
return;
}
if (p.pendingCompletePurchase) await iap.completePurchase(p);
await IAPPendingOrderService.clearPendingOrderNo();
KRCommonUtil.kr_hideLoading();
Get.toNamed(
Routes.KR_ORDER_STATUS,
arguments: {
'order': pendingOrderNo,
'payment_type': 'apple_iap',
'checkout_type': 'ipa',
},
);
}
} }
} }
// 👇 Future<void> kr_restorePurchases({bool isShowLoading = true}) async {
await tempSub.cancel(); print('重试');
completer.complete(); if(isShowLoading) {
}); KRCommonUtil.kr_showLoading();
// StoreKit
await Future.delayed(const Duration(milliseconds: 800));
if (!completer.isCompleted) {
await tempSub.cancel();
completer.complete();
} }
final iap = InAppPurchase.instance;
await iap.restorePurchases();
print('🔧 IAP 清理 pending 交易完成'); if(isShowLoading) {
KRCommonUtil.kr_hideLoading();
}
}
// 1.
int _restoreClickCount = 0;
Future<void> clearPendingOrderNo() async {
_restoreClickCount++;
// 2. 3
if (_restoreClickCount > 3) {
print('恢复购买点击次数超过3次清理待处理订单...');
await IAPPendingOrderService.clearPendingOrderNo();
_restoreClickCount = 0; //
KRCommonUtil.kr_hideLoading();
KRCommonUtil.kr_showToast('已清理缓存,请重新尝试购买');
return; //
}
} }
} }

View File

@ -17,6 +17,9 @@ import 'package:kaer_with_panels/app/utils/kr_log_util.dart';
import 'package:kaer_with_panels/app/widgets/swipe/has_swipe_config.dart'; import 'package:kaer_with_panels/app/widgets/swipe/has_swipe_config.dart';
import 'package:kaer_with_panels/app/widgets/swipe/swipe_config.dart'; import 'package:kaer_with_panels/app/widgets/swipe/swipe_config.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:kaer_with_panels/app/widgets/dialogs/hi_dialog.dart';
import '../../../routes/app_pages.dart';
/// ///
class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController> class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
@ -29,12 +32,22 @@ class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return HIBaseScaffold( //
final double topSafeArea = MediaQuery.of(context).padding.top;
final double buttonTopPosition = 80 + topSafeArea;
// Stack
return Stack(
children: [
//
HIBaseScaffold(
showBackgroundImage: false, showBackgroundImage: false,
title: '套餐选择', title: '套餐选择',
subtitle: '*所有套餐均不限流量不限速度', subtitle: '*所有套餐均不限流量不限速度',
topContentAreaHeight: 110, topContentAreaHeight: 110,
child: Stack( child: Stack(
// slogan
clipBehavior: Clip.none,
children: [ children: [
Positioned( Positioned(
left: 0, left: 0,
@ -56,7 +69,8 @@ class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
/*_kr_buildAccountSection(context),*/ /*_kr_buildAccountSection(context),*/
if (controller.kr_isLoading.value) if (controller.kr_isLoading.value)
Container( Container(
height: MediaQuery.of(context).size.height * 0.5, height:
MediaQuery.of(context).size.height * 0.5,
child: Center( child: Center(
child: KRSimpleLoading( child: KRSimpleLoading(
color: Colors.white, color: Colors.white,
@ -66,10 +80,12 @@ class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
) )
else if (controller.kr_plans.isEmpty) else if (controller.kr_plans.isEmpty)
Container( Container(
height: MediaQuery.of(context).size.height * 0.5, height:
MediaQuery.of(context).size.height * 0.5,
child: Center( child: Center(
child: Text( child: Text(
AppTranslations.kr_purchaseMembership.noData, AppTranslations
.kr_purchaseMembership.noData,
style: KrAppTextStyle( style: KrAppTextStyle(
fontSize: 14, fontSize: 14,
color: Theme.of(context) color: Theme.of(context)
@ -82,8 +98,8 @@ class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
) )
else else
Container( Container(
margin: margin: EdgeInsets.only(
EdgeInsets.only(left: 40.0 - 20, right: 40.0), left: 40.0 - 20, right: 40.0),
child: Column( child: Column(
children: [ children: [
// //
@ -96,8 +112,10 @@ class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
Column( Column(
// 使 List.generate // 使 List.generate
children: List.generate( children: List.generate(
controller.kr_getTotalOptionsCount( controller
controller.kr_plans[controller .kr_getTotalOptionsCount(
controller.kr_plans[
controller
.kr_selectedPlanIndex .kr_selectedPlanIndex
.value]), .value]),
(index) { (index) {
@ -142,22 +160,91 @@ class KRPurchaseMembershipView extends GetView<KRPurchaseMembershipController>
); );
}), }),
Positioned( Positioned(
top: 160.0, // top: 160.0,
right: 10.0, // 20.w right: 10.0,
child: GestureDetector(
onTap: () {
controller.clearPendingOrderNo();
},
child: KrLocalImage( child: KrLocalImage(
imageName: 'purchase_slogan', imageName: 'purchase_slogan',
imageType: ImageType.svg, imageType: ImageType.svg,
), ),
), ),
),
const HIHelpEntrance(isLight: false) const HIHelpEntrance(isLight: false)
], ],
), ),
), ),
// --- Positioned ---
], ],
), ),
),
// HIBaseScaffold
Positioned(
// top HIBaseScaffold
// topContentAreaHeight 110 20
top: buttonTopPosition, // 110, 10
left: 0,
right: 0,
child: _buildRestoreButton(),
),
],
);
}
///
Widget _buildRestoreButton() {
if (!Platform.isIOS ) {
return const SizedBox.shrink();
}
return Center(
child: GestureDetector(
onTap: () {
//
controller.kr_restorePurchases(isShowLoading: false); //
HIDialog.show(
title: '温馨提示',
message: '购买订单已确认,请耐心等待。若时长未到账,请联系在线客服处理。',
confirmText: '联系客服',
cancelText: '关闭',
autoClose: false,
showLoading: true,
barrierDismissible: false,
preventBackDismiss: true,
onConfirm: () async {
Get.toNamed(Routes.KR_CRISP);
},
);
},
child: Container(
height: 20,
width: 94, // 94
// padding
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
// Center 使 Text 94x20
child: Center(
child: Text(
'恢复已购项目',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
height: 1.1,
decoration: TextDecoration.none,
),
),
),
),
),
); );
} }
// //
Widget _kr_buildAccountSection(BuildContext context) { Widget _kr_buildAccountSection(BuildContext context) {
return Container( return Container(

View File

@ -2,12 +2,14 @@
abstract class Api { abstract class Api {
/// ///
static const String kr_isRegister = "/v1/auth/check"; static const String kr_isRegister = "/v1/auth/check";
/// ///
static const String kr_checkSubscription = "/v1/public/user/subscribe_status"; static const String kr_checkSubscription = "/v1/public/user/subscribe_status";
/// ///
// static const String kr_register = "/v1/auth/register"; // static const String kr_register = "/v1/auth/register";
static const String kr_register = "/v1/public/user/bind_email_with_verification"; static const String kr_register =
"/v1/public/user/bind_email_with_verification";
/// ///
static const String kr_checkVerificationCode = "/v1/auth/check-code"; static const String kr_checkVerificationCode = "/v1/auth/check-code";
@ -56,12 +58,10 @@ abstract class Api {
static const String kr_getPackageList = "/v1/public/subscribe/list"; static const String kr_getPackageList = "/v1/public/subscribe/list";
/// ///
static const String kr_getAlreadySubscribe = static const String kr_getAlreadySubscribe = "/v1/public/user/subscribe";
"/v1/public/user/subscribe";
/// OmnOem /// OmnOem
static const String kr_userAvailableSubscribe = static const String kr_userAvailableSubscribe = "/v1/public/user/subscribe";
"/v1/public/user/subscribe";
/// ///
static const String kr_renewal = "/v1/public/order/renewal"; static const String kr_renewal = "/v1/public/order/renewal";
@ -111,12 +111,13 @@ abstract class Api {
/// ///
static const String kr_getPublicPaymentMethods = "/v1/public/payment/methods"; 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_attachAppleIapTransaction =
"/v1/public/iap/apple/transactions/attach_by_id";
static const String kr_restoreAppleIap = "/v1/public/iap/apple/restore";
/// ///
static const String kr_getUserInfo = "/v1/public/user/info"; static const String kr_getUserInfo = "/v1/public/user/info";
/// ///
static const String hi_invite_code = "/v1/public/user/bind_invite_code"; static const String hi_invite_code = "/v1/public/user/bind_invite_code";
} }

View File

@ -271,7 +271,8 @@ class KRSubscribeApi {
} }
/// , /// ,
Future<Either<HttpError, KRCheckoutResponse>> kr_checkout(String orderId) async { Future<Either<HttpError, KRCheckoutResponse>> kr_checkout(
String orderId) async {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['orderNo'] = orderId; data['orderNo'] = orderId;
data['returnUrl'] = AppConfig.getInstance().baseUrl; data['returnUrl'] = AppConfig.getInstance().baseUrl;
@ -330,7 +331,8 @@ class KRSubscribeApi {
return right(baseResponse.model); return right(baseResponse.model);
} }
Future<Either<HttpError, bool>> kr_attachAppleIapTransaction(String transactionId, String orderNo) async { Future<Either<HttpError, bool>> kr_attachAppleIapTransaction(
String transactionId, String orderNo) async {
final Map<String, dynamic> data = <String, dynamic>{}; final Map<String, dynamic> data = <String, dynamic>{};
data['transaction_id'] = transactionId; data['transaction_id'] = transactionId;
data['order_no'] = orderNo; data['order_no'] = orderNo;
@ -340,10 +342,32 @@ class KRSubscribeApi {
Api.kr_attachAppleIapTransaction, Api.kr_attachAppleIapTransaction,
data, data,
method: HttpMethod.POST, method: HttpMethod.POST,
isShowLoading: true, isShowLoading: false,
); );
if (!baseResponse.isSuccess) { if (!baseResponse.isSuccess) {
return left(HttpError(msg: baseResponse.retMsg, code: baseResponse.retCode)); return left(
HttpError(msg: baseResponse.retMsg, code: baseResponse.retCode));
}
return right(baseResponse.model.kr_bl);
}
Future<Either<HttpError, bool>> kr_restoreAppleIapTransaction(
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_restoreAppleIap,
data,
method: HttpMethod.POST,
isShowLoading: false,
);
if (!baseResponse.isSuccess) {
return left(
HttpError(msg: baseResponse.retMsg, code: baseResponse.retCode));
} }
return right(baseResponse.model.kr_bl); return right(baseResponse.model.kr_bl);

View File

@ -603,6 +603,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" version: "0.6.0"
flutter_keychain:
dependency: "direct main"
description:
name: flutter_keychain
sha256: "0d000c0e9b3c16fdec016df406b4e89e7195bf719ed0882157400f1e16323cf8"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
flutter_loggy: flutter_loggy:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -70,6 +70,7 @@ dependencies:
# 存储和安全 # 存储和安全
flutter_udid: ^4.0.0 flutter_udid: ^4.0.0
flutter_keychain: 2.5.0
# 平台集成 # 平台集成
window_manager: ^0.4.3 window_manager: ^0.4.3