From cebe9c4fa058ec145fcd10aa5aa42f0de33172ea Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Mon, 17 Nov 2025 15:11:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0google=E7=9A=84=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E4=BB=A3=E7=A0=81=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/pay_service.dart | 218 +++++++++++++++++- .../controllers/goods_base_controller.dart | 149 ++++++++---- lib/modules/goods/views/goodsai_view.dart | 147 ++++++------ lib/modules/settings/views/settings_view.dart | 4 +- pubspec.yaml | 3 +- 5 files changed, 397 insertions(+), 124 deletions(-) diff --git a/lib/data/services/pay_service.dart b/lib/data/services/pay_service.dart index 62f823e0b..1f8fe1882 100644 --- a/lib/data/services/pay_service.dart +++ b/lib/data/services/pay_service.dart @@ -3,6 +3,9 @@ import 'dart:convert'; import 'dart:async'; 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:package_info_plus/package_info_plus.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../data/services/network/api.dart'; import 'package:tobias/tobias.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -22,6 +25,9 @@ class PayService extends GetxService { StreamSubscription>? _iapSubscription; final RxBool iapAvailable = false.obs; PaymentResultCallback? _iapOnResult; + // 最近一次 IAP 购买上下文,用于主动查询时携带验证参数 + PurchaseDetails? _lastIapPurchase; + ProductDetails? _lastIapProduct; String? get lastOrderId => _lastOrderId; @@ -169,11 +175,35 @@ class PayService extends GetxService { return false; } try { - final purchaseParam = PurchaseParam(productDetails: product); - final ok = await _inAppPurchase.buyConsumable( - purchaseParam: purchaseParam, - autoConsume: true, - ); + // 记录最近一次用于购买的商品详情,便于后续构建查询参数 + _lastIapProduct = product; + final isAndroid = + !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) { onResult?.call(false, '无法发起内购'); } else { @@ -188,11 +218,17 @@ class PayService extends GetxService { Future _handlePurchaseUpdates(List detailsList) async { 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) { case PurchaseStatus.pending: _iapOnResult?.call(true, '支付处理中…'); break; case PurchaseStatus.error: + print( + '[IAP] 错误: code=${details.error?.code} message=${details.error?.message}'); _iapOnResult?.call(false, details.error?.message ?? '支付错误'); break; case PurchaseStatus.purchased: @@ -250,6 +286,66 @@ class PayService extends GetxService { } } + /// 构建 Apple/Google 主动查询(pay_askorder)所需的验证参数 + /// 返回与后端 PayAskOrCancelReq 对应的 Map + Future> buildIapAskParams() async { + final params = {}; + 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() { if (kIsWeb) return 'web'; switch (defaultTargetPlatform) { @@ -397,7 +493,7 @@ class PayService extends GetxService { 'product_name': productName, 'product_desc': productDesc ?? '', 'amount': amount, - 'payment_method': paymentMethod, + 'payment_method': 4, 'client_ip': clientIp ?? '', 'notify_url': notifyUrl ?? '', 'extra_data': extraData ?? '', @@ -517,7 +613,6 @@ class PayService extends GetxService { productName: description, productDesc: description, amount: amount, - paymentMethod: 3, // 支付宝支付 notifyUrl: 'https://api.ideapsound.com/api/home/pay_alipaynotify', ); @@ -612,7 +707,6 @@ class PayService extends GetxService { required String productName, String? productDesc, required int amount, - int paymentMethod = 3, // 2表示支付宝 String? clientIp, String? notifyUrl, String? extraData, @@ -624,7 +718,7 @@ class PayService extends GetxService { 'product_name': productName, 'product_desc': productDesc ?? '', 'amount': amount, - 'payment_method': paymentMethod, + 'payment_method': 3, 'client_ip': clientIp ?? '', 'notify_url': notifyUrl ?? '', 'extra_data': extraData ?? '', @@ -679,7 +773,6 @@ class PayService extends GetxService { required String productName, String? productDesc, required int amount, - int paymentMethod = 5, // 约定:5 表示 PayPal String? clientIp, String? notifyUrl, String? extraData, @@ -690,7 +783,7 @@ class PayService extends GetxService { 'product_name': productName, 'product_desc': productDesc ?? '', 'amount': amount, - 'payment_method': paymentMethod, + 'payment_method': 5, 'client_ip': clientIp ?? '', 'notify_url': notifyUrl ?? '', 'extra_data': extraData ?? '', @@ -828,7 +921,6 @@ class PayService extends GetxService { productName: description, productDesc: description, amount: amount, - paymentMethod: 5, notifyUrl: 'https://api.ideapsound.com/api/home/pay_paypalnotify', ); @@ -843,4 +935,106 @@ class PayService extends GetxService { return false; } } + + /// 创建 Apple 内购订单,仅用于在服务端生成并记录订单号,后续回查使用 + /// 返回服务端生成的 `order_id`,若失败则返回 null + Future 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) { + final data = resp['data'] as Map; + 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 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) { + final data = resp['data'] as Map; + 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; + } + } } diff --git a/lib/modules/goods/controllers/goods_base_controller.dart b/lib/modules/goods/controllers/goods_base_controller.dart index f2a0aec77..0ff6faf38 100644 --- a/lib/modules/goods/controllers/goods_base_controller.dart +++ b/lib/modules/goods/controllers/goods_base_controller.dart @@ -455,55 +455,93 @@ abstract class GoodsBaseController extends GetxController { /// 指定 Apple 内购(App Store IAP) Future payAppleIAP(DBGoods item, {PaymentResultCallback? onResult}) async { - // 使用商品id作为内购的 productId(与后台保持一致) - final productId = item.id; - return _payService.processApplePayment( - productId: productId, - onResult: (success, msg) { - // 透传并根据阶段展示 - onResult?.call(success, msg); - if (!success) { - _showPayResultDialog(false, msg); - } else { - // 支付成功或额度待刷新时弹窗,其余阶段用轻提示 - final showDialog = msg.contains('支付成功'); - if (showDialog) { - _showPayResultDialog(true, msg); + try { + // 先创建服务端订单,记录 order_id 以便后续回查 + final orderId = await _payService.createAppleIapOrder( + productId: item.id, + productName: item.name, + productDesc: item.desc, + amount: item.price, + ); + if (orderId == null || orderId.isEmpty) { + onResult?.call(false, '创建 Apple 订单失败'); + return false; + } + + // 使用商品id作为内购的 productId(与后台保持一致) + final productId = item.id; + return _payService.processApplePayment( + productId: productId, + onResult: (success, msg) { + // 透传并根据阶段展示 + onResult?.call(success, msg); + if (!success) { + _showPayResultDialog(false, msg); } else { - Get.snackbar('提示', msg, - snackPosition: SnackPosition.BOTTOM, - margin: const EdgeInsets.all(12)); + // 支付成功或额度待刷新时弹窗,其余阶段用轻提示 + final showDialog = msg.contains('支付成功'); + if (showDialog) { + _showPayResultDialog(true, msg); + // 仅在支付成功阶段,携带校验参数主动查询订单 + _askOrderWithIapVerification(); + } else { + Get.snackbar('提示', msg, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(12)); + } } - } - }, - ); + }, + ); + } catch (e) { + onResult?.call(false, 'Apple 内购异常: $e'); + return false; + } } /// 指定 Google Play 内购(Play Billing IAP) Future payGoogleIAP(DBGoods item, {PaymentResultCallback? onResult}) async { - // 使用商品id作为内购的 productId(与后台保持一致) - final productId = item.id; - // 调试:查询并打印当前商品的内购详情,便于确认 ID - await _payService.debugQueryProducts({productId}); - return _payService.processGooglePayment( - productId: productId, - onResult: (success, msg) { - onResult?.call(success, msg); - if (!success) { - _showPayResultDialog(false, msg); - } else { - final showDialog = msg.contains('支付成功'); - if (showDialog) { - _showPayResultDialog(true, msg); + try { + // 先创建服务端订单,记录 order_id 以便后续回查 + final orderId = await _payService.createGoogleIapOrder( + productId: item.id, + productName: item.name, + productDesc: item.desc, + amount: item.price, + ); + if (orderId == null || orderId.isEmpty) { + onResult?.call(false, '创建 Google 订单失败'); + return false; + } + + // 使用商品id作为内购的 productId(与后台保持一致) + final productId = item.id; + // 调试:查询并打印当前商品的内购详情,便于确认 ID + await _payService.debugQueryProducts({productId}); + return _payService.processGooglePayment( + productId: productId, + onResult: (success, msg) { + onResult?.call(success, msg); + if (!success) { + _showPayResultDialog(false, msg); } else { - Get.snackbar('提示', msg, - snackPosition: SnackPosition.BOTTOM, - margin: const EdgeInsets.all(12)); + final showDialog = msg.contains('支付成功'); + if (showDialog) { + _showPayResultDialog(true, msg); + // 仅在支付成功阶段,携带校验参数主动查询订单 + _askOrderWithIapVerification(); + } else { + Get.snackbar('提示', msg, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(12)); + } } - } - }, - ); + }, + ); + } catch (e) { + onResult?.call(false, 'Google 内购异常: $e'); + return false; + } } /// 调试:打印当前页面所有商品的 Google IAP 查询结果 @@ -565,6 +603,37 @@ abstract class GoodsBaseController extends GetxController { _showPayResultDialog(success, message); } + /// Apple/Google 内购:无法监听平台回调时,主动查询订单并携带验证数据 + Future _askOrderWithIapVerification() async { + try { + final params = await _payService.buildIapAskParams(); + final resp = await Api.payaskorder(params); + print('[IAP] 主动查询订单 resp: $resp'); + final order = resp?['order'] as Map?; + final user = resp?['user'] as Map?; + if (order != null) { + final orderStatus = order['status']; + if (orderStatus == 1 && user != null) { + User.instance.updateUserInfo(user); + Get.snackbar('提示', '订单已确认,额度已刷新', + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(12)); + return; + } + } + // 若未确认成功,尝试回拉用户信息确保额度同步 + try { + final response = await Api.tokenlogin(); + if (response is Map && response['user'] is Map) { + User.instance + .updateUserInfo(response['user'] as Map); + } + } catch (_) {} + } catch (e) { + print('[IAP] 主动查询订单异常: $e'); + } + } + void _showPayResultDialog(bool success, String message) { final context = Get.context; final isDark = diff --git a/lib/modules/goods/views/goodsai_view.dart b/lib/modules/goods/views/goodsai_view.dart index c53c9442c..edc1cf688 100644 --- a/lib/modules/goods/views/goodsai_view.dart +++ b/lib/modules/goods/views/goodsai_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../../../data/models/goods.dart'; import '../../../data/models/user_Info.dart'; import '../controllers/goodsai_controller.dart'; @@ -228,73 +229,83 @@ class GoodsaiView extends GetView { ), ), SizedBox(height: 10.h), - Wrap( - spacing: 12.w, - runSpacing: 12.h, - alignment: WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - children: [ - _buildFeatureCard( - isDarkMode, - icon: Icons.music_note, - title: '听歌与推荐', - lines: [ - '检索曲库、播放控制', - '智能推荐歌单与相似歌曲', - ], - colorSeed: Colors.purple, - ), - _buildFeatureCard( - isDarkMode, - icon: Icons.navigation, - title: '导航与路线', - lines: [ - '路线规划与拥堵规避', - '语音播报与途径点设置', - ], - colorSeed: Colors.orange, - ), - _buildFeatureCard( - isDarkMode, - icon: Icons.wb_sunny, - title: '天气查询', - lines: [ - '实时天气与预报', - '穿衣建议与出行提醒', - ], - colorSeed: Colors.blue, - ), - _buildFeatureCard( - isDarkMode, - icon: Icons.place, - title: '附近搜索', - lines: [ - '餐饮/加油/停车等地点检索', - '评分、营业时间与路线直达', - ], - colorSeed: Colors.green, - ), - _buildFeatureCard( - isDarkMode, - icon: Icons.newspaper, - title: '新闻资讯', - lines: [ - '热点快讯与专题速览', - '长文总结与要点提炼', - ], - colorSeed: Colors.indigo, - ), - _buildFeatureCard( - isDarkMode, - icon: Icons.call, - title: '通讯助手', - lines: [ - '帮忙拨打电话与发短信', - '模板消息与日程提醒', - ], - colorSeed: Colors.teal, - ), - ], + MasonryGridView.count( + crossAxisCount: 2, + mainAxisSpacing: 12.h, + crossAxisSpacing: 12.w, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 6, + itemBuilder: (context, index) { + switch (index) { + case 0: + return _buildFeatureCard( + isDarkMode, + icon: Icons.music_note, + title: '听歌与推荐', + lines: [ + '检索曲库、播放控制', + '智能推荐歌单与相似歌曲', + ], + colorSeed: Colors.purple, + ); + case 1: + return _buildFeatureCard( + isDarkMode, + icon: Icons.navigation, + title: '导航与路线', + lines: [ + '路线规划与拥堵规避', + '语音播报与途径点设置', + ], + colorSeed: Colors.orange, + ); + case 2: + return _buildFeatureCard( + isDarkMode, + icon: Icons.wb_sunny, + title: '天气查询', + lines: [ + '实时天气与预报', + '穿衣建议与出行提醒', + ], + colorSeed: Colors.blue, + ); + case 3: + return _buildFeatureCard( + isDarkMode, + icon: Icons.place, + title: '附近搜索', + lines: [ + '餐饮/加油/停车等地点检索', + '评分、营业时间与路线直达', + ], + colorSeed: Colors.green, + ); + case 4: + return _buildFeatureCard( + isDarkMode, + icon: Icons.newspaper, + title: '新闻资讯', + lines: [ + '热点快讯与专题速览', + '长文总结与要点提炼', + ], + colorSeed: Colors.indigo, + ); + default: + return _buildFeatureCard( + isDarkMode, + icon: Icons.call, + title: '通讯助手', + lines: [ + '帮忙拨打电话与发短信', + '模板消息与日程提醒', + ], + colorSeed: Colors.teal, + ); + } + }, ), SizedBox(height: 12.h), Text( @@ -349,9 +360,7 @@ class GoodsaiView extends GetView { final bgColor = isDarkMode ? Colors.grey[900] : Colors.white; final mainColor = isDarkMode ? colorSeed[300]! : colorSeed[600]!; final subColor = isDarkMode ? Colors.white70 : Colors.grey[700]; - final width = (Get.width - 16.w * 2 - 12.w) / 2; return Container( - width: width, padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: bgColor, diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index a94493ef4..febe0493c 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -265,7 +265,7 @@ class SettingsView extends GetView { onTap: () => Get.toNamed(Routes.goodstrade), ), _buildCategoryItem( - icon: Icons.people_alt, + icon: Icons.file_open, label: "Meet", iconBg: isDarkMode ? Colors.pink[900]!.withOpacity(0.3) @@ -274,7 +274,7 @@ class SettingsView extends GetView { onTap: () => Get.toNamed(Routes.goodsmeet), ), _buildCategoryItem( - icon: Icons.auto_awesome, + icon: Icons.smart_toy, label: "AI", iconBg: isDarkMode ? Colors.teal[900]!.withOpacity(0.3) diff --git a/pubspec.yaml b/pubspec.yaml index 7f00789b2..52bdd5bc4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.18+53 +version: 1.0.18+54 environment: sdk: ">=3.3.0 <4.0.0" @@ -19,6 +19,7 @@ dependencies: get_storage: ^2.1.1 http: ^1.3.0 flutter_screenutil: ^5.9.0 + flutter_staggered_grid_view: ^0.7.0 path_provider: ^2.1.5 audio_session: ^0.1.18 flutter_markdown: ^0.7.7+1