436 lines
19 KiB
Dart
436 lines
19 KiB
Dart
import 'dart:async';
|
||
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 上可能不会出现在 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 [];
|
||
}
|
||
final iap = InAppPurchase.instance;
|
||
if (!await iap.isAvailable()) {
|
||
_log.w('Billing 不可用');
|
||
return [];
|
||
}
|
||
try {
|
||
// 先订阅 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;
|
||
orderIdsFromQuery.add(b.orderId);
|
||
list.add(UnacknowledgedGooglePayPurchase(
|
||
orderId: b.orderId,
|
||
productId: p.productID,
|
||
payload: GooglePayVerificationPayload(
|
||
purchaseData: b.originalJson,
|
||
signature: b.signature,
|
||
),
|
||
purchaseDetails: p,
|
||
));
|
||
}
|
||
// 未确认的购买在 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');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// 对单笔购买执行 completePurchase,并在 Android 上显式调用 consumePurchase(autoConsume: 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) {
|
||
_log.d('非 Android,跳过内购');
|
||
return null;
|
||
}
|
||
final iap = InAppPurchase.instance;
|
||
if (!await iap.isAvailable()) {
|
||
_log.w('Billing 不可用');
|
||
return null;
|
||
}
|
||
final response = await iap.queryProductDetails({productId});
|
||
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
|
||
_log.w(
|
||
'商品未找到: 请求的 productId="$productId", notFoundIDs=${response.notFoundIDs}, 请核对与 Play 后台配置的「产品 ID」是否完全一致(区分大小写)');
|
||
return null;
|
||
}
|
||
final product = response.productDetails.first;
|
||
final completer = Completer<GooglePayPurchaseResult?>();
|
||
StreamSubscription<List<PurchaseDetails>>? sub;
|
||
sub = iap.purchaseStream.listen(
|
||
(purchases) {
|
||
// 把 purchases 转为 JSON 输出,方便调试
|
||
try {
|
||
final list = purchases.map((p) {
|
||
final base = <String, Object?>{
|
||
'productID': p.productID,
|
||
'status': p.status.toString(),
|
||
'transactionDate': p.transactionDate,
|
||
'verificationData': {
|
||
'serverVerificationData':
|
||
p.verificationData.serverVerificationData,
|
||
'localVerificationData':
|
||
p.verificationData.localVerificationData,
|
||
'source': p.verificationData.source,
|
||
},
|
||
'pendingCompletePurchase': p.pendingCompletePurchase,
|
||
'error': p.error?.message,
|
||
};
|
||
if (p is GooglePlayPurchaseDetails) {
|
||
final b = p.billingClientPurchase;
|
||
base['googlePlay'] = {
|
||
'orderId': b.orderId,
|
||
'packageName': b.packageName,
|
||
'purchaseTime': b.purchaseTime,
|
||
'purchaseToken': b.purchaseToken,
|
||
'signature': b.signature,
|
||
'originalJson': b.originalJson,
|
||
'isAcknowledged': b.isAcknowledged,
|
||
'purchaseState': b.purchaseState.toString(),
|
||
};
|
||
}
|
||
return base;
|
||
}).toList();
|
||
_log.d('Google Play purchases json: ${jsonEncode(list)}');
|
||
logWithEmbeddedJson(jsonEncode(list));
|
||
} catch (e) {
|
||
_log.w('序列化 purchases 失败: $e');
|
||
}
|
||
|
||
for (final p in purchases) {
|
||
if (p.productID != productId) continue;
|
||
if (p.status == PurchaseStatus.purchased ||
|
||
p.status == PurchaseStatus.restored) {
|
||
_log.d('购买成功: ${p.toString()}');
|
||
if (!completer.isCompleted && p is GooglePlayPurchaseDetails) {
|
||
final b = p.billingClientPurchase;
|
||
_pendingFromStream[b.orderId] = p;
|
||
completer.complete(GooglePayPurchaseResult(
|
||
orderId: b.orderId,
|
||
payload: GooglePayVerificationPayload(
|
||
purchaseData: b.originalJson,
|
||
signature: b.signature,
|
||
),
|
||
purchaseDetails: p,
|
||
));
|
||
}
|
||
sub?.cancel();
|
||
return;
|
||
}
|
||
if (p.status == PurchaseStatus.error ||
|
||
p.status == PurchaseStatus.canceled) {
|
||
if (!completer.isCompleted) completer.complete(null);
|
||
sub?.cancel();
|
||
return;
|
||
}
|
||
}
|
||
},
|
||
onError: (e) {
|
||
if (!completer.isCompleted) completer.complete(null);
|
||
sub?.cancel();
|
||
},
|
||
);
|
||
final success = await iap.buyConsumable(
|
||
purchaseParam: PurchaseParam(productDetails: product),
|
||
autoConsume: false,
|
||
);
|
||
if (!success) {
|
||
sub.cancel();
|
||
return null;
|
||
}
|
||
return completer.future.timeout(
|
||
const Duration(seconds: 120),
|
||
onTimeout: () {
|
||
sub?.cancel();
|
||
return null;
|
||
},
|
||
);
|
||
}
|
||
}
|