优化:谷歌支付补单功能

This commit is contained in:
ivan 2026-03-13 20:09:50 +08:00
parent d2e7c0ac8f
commit bde8db3673
4 changed files with 425 additions and 84 deletions

View File

@ -3,22 +3,106 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:pets_hero_ai/core/api/api.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/api/services/payment_api.dart';
import '../../core/log/app_logger.dart';
import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart';
import 'models/google_pay_purchase_result.dart';
import 'models/google_pay_verification_payload.dart';
import 'models/unacknowledged_google_pay_purchase.dart';
const String _kFederationMapKey = 'google_pay_google_order_to_federation';
/// Google Play
abstract final class GooglePlayPurchaseService {
static final _log = AppLogger('GooglePlayPurchase');
///
/// 使 Android [InAppPurchaseAndroidPlatformAddition.queryPastPurchases] /
/// [isAcknowledged == false] Android Android
///
static Future<List<UnacknowledgedGooglePayPurchase>> getUnacknowledgedPurchases() async {
/// Android queryPastPurchases
/// purchaseStream stream pending 使
static final Map<String, PurchaseDetails> _pendingFromStream = {};
static StreamSubscription<List<PurchaseDetails>>? _pendingStreamSub;
/// Android purchaseStream complete
/// queryPastPurchases
static void startPendingPurchaseListener() {
if (defaultTargetPlatform != TargetPlatform.android) return;
if (_pendingStreamSub != null) return;
final iap = InAppPurchase.instance;
_pendingStreamSub =
iap.purchaseStream.listen((List<PurchaseDetails> purchases) {
for (final p in purchases) {
if (p is! GooglePlayPurchaseDetails) continue;
if (!p.pendingCompletePurchase) continue;
final orderId = p.billingClientPurchase.orderId;
if (orderId.isEmpty) continue;
_pendingFromStream[orderId] = p;
_log.d('purchaseStream 收到待处理订单 orderId=$orderId,已加入补单候选');
}
}, onError: (e) {
_log.w('purchaseStream 错误: $e');
});
_log.d('已订阅 purchaseStream用于补单时获取未确认订单');
}
/// Google orderId federation使
static Future<void> saveFederationForGoogleOrderId(
String googleOrderId, String federation) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kFederationMapKey);
final map = json != null
? Map<String, String>.from((jsonDecode(json) as Map)
.map((k, v) => MapEntry(k.toString(), v.toString())))
: <String, String>{};
map[googleOrderId] = federation;
await prefs.setString(_kFederationMapKey, jsonEncode(map));
} catch (e) {
_log.w('保存 federation 映射失败: $e');
}
}
/// Google orderId federation null
static Future<String?> getFederationForGoogleOrderId(
String googleOrderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kFederationMapKey);
if (json == null) return null;
final map = (jsonDecode(json) as Map)
.map((k, v) => MapEntry(k.toString(), v.toString()));
final v = map[googleOrderId]?.toString();
return (v != null && v.isNotEmpty) ? v : null;
} catch (e) {
_log.w('读取 federation 映射失败: $e');
return null;
}
}
///
static Future<void> removeFederationForGoogleOrderId(
String googleOrderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kFederationMapKey);
if (json == null) return;
final map = Map<String, String>.from((jsonDecode(json) as Map)
.map((k, v) => MapEntry(k.toString(), v.toString())));
map.remove(googleOrderId);
await prefs.setString(_kFederationMapKey, jsonEncode(map));
} catch (e) {
_log.w('移除 federation 映射失败: $e');
}
}
/// consume
/// 使 [queryPastPurchases] purchaseStream isAcknowledged Android
static Future<List<UnacknowledgedGooglePayPurchase>>
getUnacknowledgedPurchases() async {
if (defaultTargetPlatform != TargetPlatform.android) {
_log.d('非 Android无未核销订单');
return [];
@ -29,16 +113,74 @@ abstract final class GooglePlayPurchaseService {
return [];
}
try {
final androidAddition = iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
// stream queryPastPurchases Billing stream
startPendingPurchaseListener();
final androidAddition =
iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final response = await androidAddition.queryPastPurchases();
if (response.error != null) {
_log.w('queryPastPurchases 错误: ${response.error!.message}');
return [];
}
// response toString
_log.d(
'queryPastPurchases response contains ${response.pastPurchases.length} pastPurchases.');
for (var i = 0; i < response.pastPurchases.length; i++) {
final purchase = response.pastPurchases[i];
final b = purchase.billingClientPurchase;
_log.d('pastPurchase[$i]:');
_log.d(' productID: ${purchase.productID}');
_log.d(' purchaseID: ${purchase.purchaseID}');
_log.d(' transactionDate: ${purchase.transactionDate}');
_log.d(' status: ${purchase.status}');
_log.d(' error: ${purchase.error}');
_log.d(
' pendingCompletePurchase: ${purchase.pendingCompletePurchase}');
_log.d(' billingClientPurchase:');
_log.d(' orderId: ${b.orderId}');
_log.d(' purchaseToken: ${b.purchaseToken}');
_log.d(' packageName: ${b.packageName}');
_log.d(' productID: ${purchase.productID}');
_log.d(' purchaseState: ${b.purchaseState}');
_log.d(' isAcknowledged: ${b.isAcknowledged}');
_log.d(' isAutoRenewing: ${b.isAutoRenewing}');
_log.d(' originalJson (length): ${b.originalJson.length}');
_log.d(' signature (length): ${b.signature.length}');
}
for (final purchase in response.pastPurchases) {
final b = purchase.billingClientPurchase;
final transactionDate = purchase.transactionDate;
String? formattedDate;
if (transactionDate != null && transactionDate.isNotEmpty) {
try {
// transactionDate is milliseconds since epoch (string)
final millis = int.tryParse(transactionDate);
if (millis != null) {
final dt = DateTime.fromMillisecondsSinceEpoch(millis,
isUtc: false); // Android is local?
// Format as yyyy-MM-dd HH:mm:ss
formattedDate =
"${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} "
"${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}";
_log.d('订单日期: $formattedDate');
} else {
_log.d('订单日期解析失败: $transactionDate');
}
} catch (e) {
_log.d('订单日期格式化异常: $e, 原数据: $transactionDate');
}
} else {
_log.d('订单日期为空');
}
_log.d(
'订单: orderId=${b.orderId}, productId=${purchase.productID}, isAcknowledged=${b.isAcknowledged}, purchaseDataLength=${b.originalJson.length}, signatureLength=${b.signature.length}');
}
// isAcknowledged consume
final list = <UnacknowledgedGooglePayPurchase>[];
final orderIdsFromQuery = <String>{};
for (final p in response.pastPurchases) {
final b = p.billingClientPurchase;
if (b.isAcknowledged) continue;
orderIdsFromQuery.add(b.orderId);
list.add(UnacknowledgedGooglePayPurchase(
orderId: b.orderId,
productId: p.productID,
@ -46,9 +188,41 @@ abstract final class GooglePlayPurchaseService {
purchaseData: b.originalJson,
signature: b.signature,
),
purchaseDetails: p,
));
}
_log.d('未核销订单数: ${list.length}');
// Android queryPastPurchases purchaseStream
// stream Billing
await Future<void>.delayed(const Duration(milliseconds: 1500));
for (final entry in _pendingFromStream.entries) {
if (orderIdsFromQuery.contains(entry.key)) continue;
final p = entry.value;
if (p is! GooglePlayPurchaseDetails) continue;
final b = p.billingClientPurchase;
list.add(UnacknowledgedGooglePayPurchase(
orderId: b.orderId,
productId: p.productID,
payload: GooglePayVerificationPayload(
purchaseData: b.originalJson,
signature: b.signature,
),
purchaseDetails: p,
));
_log.d('未核销订单(来自 stream) orderId=${b.orderId} 已合并');
}
_log.d(
'未核销订单数: ${list.length} (query: ${orderIdsFromQuery.length}, stream: ${_pendingFromStream.length})');
for (var i = 0; i < list.length; i++) {
final u = list[i];
_log.d('未核销[$i] orderId=${u.orderId} productId=${u.productId} '
'purchaseDataLength=${u.payload.purchaseData.length}');
logWithEmbeddedJson(jsonEncode({
'orderId': u.orderId,
'productId': u.productId,
'purchaseData': u.payload.purchaseData,
'signatureLength': u.payload.signature.length,
}));
}
return list;
} catch (e, st) {
_log.w('获取未核销订单失败: $e\n$st');
@ -56,9 +230,99 @@ abstract final class GooglePlayPurchaseService {
}
}
/// purchaseData=originalJson, signature
/// [GooglePayVerificationPayload]/ null idfederationuserId merchant
static Future<GooglePayVerificationPayload?> launchPurchaseAndReturnData(
/// completePurchase Android consumePurchaseautoConsume: false
/// [GooglePayPurchaseResult.purchaseDetails]
static Future<bool> completeAndConsumePurchase(PurchaseDetails purchaseDetails) async {
final iap = InAppPurchase.instance;
try {
iap.completePurchase(purchaseDetails);
_log.d('completePurchase 已执行');
if (defaultTargetPlatform == TargetPlatform.android) {
final androidAddition = iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final result = await androidAddition.consumePurchase(purchaseDetails);
final ok = result.responseCode == BillingResponse.ok;
if (ok) {
_log.d('consumePurchase 已执行,可再次购买');
} else {
_log.w('consumePurchase 未成功 responseCode=${result.responseCode}');
}
return ok;
}
return true;
} catch (e, st) {
_log.w('completePurchase/consumePurchase 异常: $e\n$st');
return false;
}
}
/// completePurchase + consumePurchase
static Future<bool> _consumePurchase(InAppPurchase iap, UnacknowledgedGooglePayPurchase p) async {
return completeAndConsumePurchase(p.purchaseDetails);
}
/// federation completePurchase federation completePurchase
/// Android
static Future<void> runOrderRecovery() async {
if (defaultTargetPlatform != TargetPlatform.android) return;
final userId = UserState.userId.value;
if (userId == null || userId.isEmpty) {
_log.d('补单跳过:未登录');
return;
}
final pending = await getUnacknowledgedPurchases();
if (pending.isEmpty) return;
_log.d('补单开始,待处理 ${pending.length}');
final iap = InAppPurchase.instance;
bool needRefresh = false;
for (final p in pending) {
try {
final federation = await getFederationForGoogleOrderId(p.orderId);
if (federation != null && federation.isNotEmpty) {
final res = await PaymentApi.googlepay(
sample: p.payload.signature,
merchant: p.payload.purchaseData,
federation: federation,
asset: userId,
);
if (!res.isSuccess) {
_log.w('补单失败 orderId=${p.orderId}: ${res.msg}');
continue;
}
final data = res.data is Map<String, dynamic>
? res.data as Map<String, dynamic>
: null;
final line = (data?['line']?.toString() ?? '').toUpperCase();
final status = (data?['status']?.toString() ?? '').toUpperCase();
final isSuccess = line == 'SUCCESS' || status == 'SUCCESS';
_log.d('补单响应 orderId=${p.orderId} data=$data line=$line status=$status isSuccess=$isSuccess');
if (isSuccess) {
if (await _consumePurchase(iap, p)) {
_pendingFromStream.remove(p.orderId);
await removeFederationForGoogleOrderId(p.orderId);
needRefresh = true;
_log.d('补单成功 orderId=${p.orderId} federation=$federation');
}
} else {
_log.w('补单服务端未成功 orderId=${p.orderId} line=$line status=$status');
}
} else {
// federation completePurchase + consumePurchase
_log.d('补单无 federation仅执行 consume 以解除「已拥有此内容」orderId=${p.orderId}');
if (await _consumePurchase(iap, p)) {
_pendingFromStream.remove(p.orderId);
needRefresh = true;
}
}
} catch (e, st) {
_log.w('补单异常 orderId=${p.orderId}: $e\n$st');
}
}
if (needRefresh) await refreshAccount();
}
/// [PurchaseDetails]
/// [GooglePayPurchaseResult] result.purchaseDetails [InAppPurchase.instance.completePurchase]
static Future<GooglePayPurchaseResult?> launchPurchaseAndReturnData(
String productId) async {
_log.d('谷歌支付请求商品 ID(helm): "$productId"');
if (defaultTargetPlatform != TargetPlatform.android) {
@ -77,7 +341,7 @@ abstract final class GooglePlayPurchaseService {
return null;
}
final product = response.productDetails.first;
final completer = Completer<GooglePayVerificationPayload?>();
final completer = Completer<GooglePayPurchaseResult?>();
StreamSubscription<List<PurchaseDetails>>? sub;
sub = iap.purchaseStream.listen(
(purchases) {
@ -124,17 +388,17 @@ abstract final class GooglePlayPurchaseService {
if (p.status == PurchaseStatus.purchased ||
p.status == PurchaseStatus.restored) {
_log.d('购买成功: ${p.toString()}');
if (!completer.isCompleted) {
GooglePayVerificationPayload? payload;
if (p is GooglePlayPurchaseDetails) {
if (!completer.isCompleted && p is GooglePlayPurchaseDetails) {
final b = p.billingClientPurchase;
payload = GooglePayVerificationPayload(
_pendingFromStream[b.orderId] = p;
completer.complete(GooglePayPurchaseResult(
orderId: b.orderId,
payload: GooglePayVerificationPayload(
purchaseData: b.originalJson,
signature: b.signature,
);
}
iap.completePurchase(p);
completer.complete(payload);
),
purchaseDetails: p,
));
}
sub?.cancel();
return;
@ -154,6 +418,7 @@ abstract final class GooglePlayPurchaseService {
);
final success = await iap.buyConsumable(
purchaseParam: PurchaseParam(productDetails: product),
autoConsume: false,
);
if (!success) {
sub.cancel();

View File

@ -0,0 +1,23 @@
import 'package:in_app_purchase/in_app_purchase.dart';
import 'google_pay_verification_payload.dart';
///
/// line == 'SUCCESS' [purchaseDetails]
/// [InAppPurchase.instance.completePurchase]
class GooglePayPurchaseResult {
const GooglePayPurchaseResult({
required this.orderId,
required this.payload,
required this.purchaseDetails,
});
/// Google Play googlepay federation createPayment
final String orderId;
/// POST /v1/payment/googlepay sample/merchant
final GooglePayVerificationPayload payload;
/// [InAppPurchase.instance.completePurchase]
final PurchaseDetails purchaseDetails;
}

View File

@ -1,13 +1,17 @@
import 'package:in_app_purchase/in_app_purchase.dart';
import 'google_pay_verification_payload.dart';
///
/// [GooglePlayPurchaseService.getUnacknowledgedPurchases]
/// federation [orderId] createPayment
/// [purchaseDetails] [InAppPurchase.instance.completePurchase]
class UnacknowledgedGooglePayPurchase {
const UnacknowledgedGooglePayPurchase({
required this.orderId,
required this.productId,
required this.payload,
required this.purchaseDetails,
});
/// Google Play purchase orderId
@ -18,4 +22,7 @@ class UnacknowledgedGooglePayPurchase {
/// POST /v1/payment/googlepay sample/merchantfederation/asset
final GooglePayVerificationPayload payload;
/// [InAppPurchase.instance.completePurchase]
final PurchaseDetails purchaseDetails;
}

View File

@ -44,6 +44,8 @@ class _RechargeScreenState extends State<RechargeScreen>
WidgetsBinding.instance.addObserver(this);
refreshAccount();
_fetchActivities();
//
GooglePlayPurchaseService.runOrderRecovery();
}
@override
@ -206,8 +208,7 @@ class _RechargeScreenState extends State<RechargeScreen>
await _createOrderAndOpenUrl(
userId: userId,
activityId: activityId,
productId: item.code,
item: item,
paymentMethod: selected.paymentMethod,
subPaymentMethod: selected.subPaymentMethod?.isEmpty == true
? null
@ -225,17 +226,17 @@ class _RechargeScreenState extends State<RechargeScreen>
}
}
/// Google Pay
/// Google Pay [_launchGooglePlayPurchase]
Future<void> _createOrderAndOpenUrl({
required String userId,
required String activityId,
required String productId,
required ActivityItem item,
required String paymentMethod,
String? subPaymentMethod,
}) async {
if (!mounted) return;
try {
final activityId = item.activityId ?? '';
final createRes = await PaymentApi.createPayment(
sentinel: ApiConfig.appId,
asset: userId,
@ -260,54 +261,8 @@ class _RechargeScreenState extends State<RechargeScreen>
final orderId = data?['federation']?.toString();
if (_isGooglePay(paymentMethod, subPaymentMethod)) {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.',
isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
if (mounted) setState(() => _loadingProductId = null);
final payload =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
productId);
if (!mounted) return;
if (payload != null &&
orderId != null &&
orderId.isNotEmpty) {
RechargeScreen._log.d('googlepay 入参: federation=$orderId');
final googlepayRes = await PaymentApi.googlepay(
sample: payload.signature,
merchant: payload.purchaseData,
federation: orderId,
asset: userId,
);
if (!mounted) return;
if (googlepayRes.isSuccess) {
final resData = googlepayRes.data is Map<String, dynamic>
? googlepayRes.data as Map<String, dynamic>
: null;
final line = (resData?['line']?.toString() ?? '').toUpperCase();
if (line == 'SUCCESS') {
await refreshAccount();
}
if (mounted) {
_showSnackBar(context, 'Purchase completed.');
}
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(
context,
googlepayRes.msg.isNotEmpty
? googlepayRes.msg
: 'Payment verification failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
}
} else {
_showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
}
await _launchGooglePlayPurchase(item,
serverOrderId: orderId, userId: userId);
return;
}
@ -351,7 +306,7 @@ class _RechargeScreenState extends State<RechargeScreen>
return r == 'googlepay' || c == 'googlepay';
}
/// false
/// false Google Pay
Future<void> _runGooglePay(ActivityItem item) async {
if (defaultTargetPlatform != TargetPlatform.android) {
_showSnackBar(context, 'Google Pay is only available on Android.',
@ -359,27 +314,118 @@ class _RechargeScreenState extends State<RechargeScreen>
AdjustEvents.trackPaymentFailed();
return;
}
await _launchGooglePlayPurchase(item);
final userId = UserState.userId.value;
if (userId == null || userId.isEmpty) {
_showSnackBar(context, 'Please sign in to continue.', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final activityId = item.activityId;
if (activityId == null || activityId.isEmpty) {
if (mounted) _showSnackBar(context, 'Invalid product', isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
try {
final createRes = await PaymentApi.createPayment(
sentinel: ApiConfig.appId,
asset: userId,
warrior: activityId,
resource: 'GooglePay',
ceremony: 'GooglePay',
);
if (!mounted) return;
if (!createRes.isSuccess) {
_showSnackBar(
context,
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create order',
isError: true,
);
AdjustEvents.trackPaymentFailed();
return;
}
final data = createRes.data is Map<String, dynamic>
? createRes.data as Map<String, dynamic>
: null;
final orderId = data?['federation']?.toString();
await _launchGooglePlayPurchase(item,
serverOrderId: orderId, userId: userId);
} catch (e) {
if (mounted) {
_showSnackBar(context, 'Payment error: ${e.toString()}', isError: true);
}
AdjustEvents.trackPaymentFailed();
} finally {
if (mounted) setState(() => _loadingProductId = null);
}
}
/// Google Play ID = item.code / helm
Future<void> _launchGooglePlayPurchase(ActivityItem item) async {
/// googlepay
/// [serverOrderId] createPayment federation Google orderId
Future<void> _launchGooglePlayPurchase(ActivityItem item,
{String? serverOrderId, String? userId}) async {
if (!mounted) return;
try {
if (mounted) setState(() => _loadingProductId = null);
final payload =
final result =
await GooglePlayPurchaseService.launchPurchaseAndReturnData(
item.code);
if (!mounted) return;
if (payload != null) {
_showSnackBar(context, 'Purchase completed.');
AdjustEvents.trackPurchaseSuccess();
// createPayment federation googlepay
} else {
if (result == null) {
_showSnackBar(context, 'Purchase was cancelled or failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final uid = userId ?? UserState.userId.value;
if (uid == null || uid.isEmpty) {
_showSnackBar(context, 'Please sign in to confirm your purchase.',
isError: true);
AdjustEvents.trackPaymentFailed();
return;
}
final federation = (serverOrderId != null && serverOrderId.isNotEmpty)
? serverOrderId
: result.orderId;
if (serverOrderId != null && serverOrderId.isNotEmpty) {
await GooglePlayPurchaseService.saveFederationForGoogleOrderId(
result.orderId, serverOrderId);
}
RechargeScreen._log.d('googlepay 入参: federation=$federation');
final googlepayRes = await PaymentApi.googlepay(
sample: result.payload.signature,
merchant: result.payload.purchaseData,
federation: federation,
asset: uid,
);
if (!mounted) return;
if (googlepayRes.isSuccess) {
final resData = googlepayRes.data is Map<String, dynamic>
? googlepayRes.data as Map<String, dynamic>
: null;
final line = (resData?['line']?.toString() ?? '').toUpperCase();
if (line == 'SUCCESS') {
await GooglePlayPurchaseService.completeAndConsumePurchase(
result.purchaseDetails);
if (serverOrderId != null && serverOrderId.isNotEmpty) {
await GooglePlayPurchaseService.removeFederationForGoogleOrderId(
result.orderId);
}
await refreshAccount();
}
if (mounted) {
_showSnackBar(context, 'Purchase completed.');
}
AdjustEvents.trackPurchaseSuccess();
} else {
_showSnackBar(
context,
googlepayRes.msg.isNotEmpty
? googlepayRes.msg
: 'Payment verification failed.',
isError: true);
AdjustEvents.trackPaymentFailed();
}
} catch (e) {
if (mounted) {