feat: 实现 iOS Apple Pay 内购支付功能
前端: - 集成 in_app_purchase 插件,实现 IAP 支付流程 - 添加支付模块 (payments/) 处理产品获取、购买、验证 - 积分中心页面集成 Apple Pay 购买入口 - 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面 - 修复欢迎引导页滚动检测阈值问题 - 修复解卦结果页 iOS 侧滑返回手势被阻止的问题 - 邀请码绑定按钮临时禁用(待后端实现) 后端: - 新增 apple_iap_transactions 表记录交易 - 实现 Apple 服务器端验证 (App Store Server API) - 支付成功后自动发放积分 - 支持 Sandbox/Production 环境切换 - 添加退款处理和交易状态机 协议: - 更新积分流水协议,支持 purchase/refund 类型 - 新增 PAYMENT_* 错误码
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../models/apple_purchase_models.dart';
|
||||
|
||||
class ApplePaymentApi {
|
||||
const ApplePaymentApi({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<VerifyTransactionResponse> verifyTransaction(
|
||||
VerifyTransactionRequest request,
|
||||
) async {
|
||||
final json = await _apiClient.postJson(
|
||||
'/api/v1/payments/apple/transactions/verify',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return VerifyTransactionResponse.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
class VerifyTransactionRequest {
|
||||
const VerifyTransactionRequest({
|
||||
required this.productCode,
|
||||
required this.appStoreProductId,
|
||||
required this.transactionId,
|
||||
required this.signedTransactionInfo,
|
||||
this.appAccountToken,
|
||||
});
|
||||
|
||||
final String productCode;
|
||||
final String appStoreProductId;
|
||||
final String transactionId;
|
||||
final String signedTransactionInfo;
|
||||
final String? appAccountToken;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'productCode': productCode,
|
||||
'appStoreProductId': appStoreProductId,
|
||||
'transactionId': transactionId,
|
||||
'signedTransactionInfo': signedTransactionInfo,
|
||||
if (appAccountToken != null) 'appAccountToken': appAccountToken,
|
||||
};
|
||||
}
|
||||
|
||||
enum VerifyTransactionStatus { granted, alreadyGranted }
|
||||
|
||||
class VerifyTransactionResponse {
|
||||
const VerifyTransactionResponse({
|
||||
required this.status,
|
||||
required this.productCode,
|
||||
required this.transactionId,
|
||||
required this.creditsAdded,
|
||||
required this.newBalance,
|
||||
required this.ledgerEventId,
|
||||
});
|
||||
|
||||
final VerifyTransactionStatus status;
|
||||
final String productCode;
|
||||
final String transactionId;
|
||||
final int creditsAdded;
|
||||
final int newBalance;
|
||||
final String ledgerEventId;
|
||||
|
||||
factory VerifyTransactionResponse.fromJson(Map<String, dynamic> json) {
|
||||
return VerifyTransactionResponse(
|
||||
status: json['status'] == 'already_granted'
|
||||
? VerifyTransactionStatus.alreadyGranted
|
||||
: VerifyTransactionStatus.granted,
|
||||
productCode: json['productCode'] as String,
|
||||
transactionId: json['transactionId'] as String,
|
||||
creditsAdded: json['creditsAdded'] as int,
|
||||
newBalance: json['newBalance'] as int,
|
||||
ledgerEventId: json['ledgerEventId'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../apis/apple_payment_api.dart';
|
||||
import '../models/apple_purchase_models.dart';
|
||||
|
||||
enum PurchaseFlowState { idle, purchasing, verifying, success, failed }
|
||||
|
||||
class PurchaseResult {
|
||||
const PurchaseResult({
|
||||
required this.state,
|
||||
this.creditsAdded,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final PurchaseFlowState state;
|
||||
final int? creditsAdded;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
class AppleIapService with ChangeNotifier {
|
||||
AppleIapService({
|
||||
required ApiClient apiClient,
|
||||
required String userId,
|
||||
}) : _paymentApi = ApplePaymentApi(apiClient: apiClient),
|
||||
_inAppPurchase = InAppPurchase.instance,
|
||||
_appAccountToken = _hashUserId(userId);
|
||||
|
||||
final ApplePaymentApi _paymentApi;
|
||||
final InAppPurchase _inAppPurchase;
|
||||
final String? _appAccountToken;
|
||||
final Logger _logger = getLogger('features.payments.apple_iap_service');
|
||||
|
||||
static String? _hashUserId(String userId) {
|
||||
if (userId.isEmpty) return null;
|
||||
final bytes = utf8.encode(userId);
|
||||
final digest = md5.convert(bytes);
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
||||
Map<String, ProductDetails> _storeKitProducts = {};
|
||||
PurchaseFlowState _state = PurchaseFlowState.idle;
|
||||
String? _lastError;
|
||||
ApiProblem? _lastApiProblem;
|
||||
|
||||
PurchaseFlowState get state => _state;
|
||||
String? get lastError => _lastError;
|
||||
ApiProblem? get lastApiProblem => _lastApiProblem;
|
||||
|
||||
void init() {
|
||||
final Stream<List<PurchaseDetails>> purchaseUpdated;
|
||||
purchaseUpdated = _inAppPurchase.purchaseStream;
|
||||
_subscription = purchaseUpdated.listen(_onPurchaseUpdated);
|
||||
}
|
||||
|
||||
Future<void> loadStoreKitProducts(List<PackageInfo> packages) async {
|
||||
final ids = packages
|
||||
.map((p) => p.appStoreProductId)
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet();
|
||||
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final response = await _inAppPurchase.queryProductDetails(ids);
|
||||
if (response.notFoundIDs.isNotEmpty) {
|
||||
_logger.warning(
|
||||
message: 'Some StoreKit products not found',
|
||||
extra: {'notFound': response.notFoundIDs.join(', ')},
|
||||
);
|
||||
}
|
||||
|
||||
final products = <String, ProductDetails>{};
|
||||
for (final detail in response.productDetails) {
|
||||
products[detail.id] = detail;
|
||||
}
|
||||
_storeKitProducts = products;
|
||||
}
|
||||
|
||||
ProductDetails? getStoreKitProduct(String appStoreProductId) {
|
||||
return _storeKitProducts[appStoreProductId];
|
||||
}
|
||||
|
||||
Future<bool> purchase(PackageInfo package) async {
|
||||
if (_state == PurchaseFlowState.purchasing ||
|
||||
_state == PurchaseFlowState.verifying) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final product = _storeKitProducts[package.appStoreProductId];
|
||||
if (product == null) {
|
||||
_logger.warning(
|
||||
message: 'StoreKit product not found for purchase',
|
||||
extra: {'productId': package.appStoreProductId},
|
||||
);
|
||||
_setError('Product not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setState(PurchaseFlowState.purchasing);
|
||||
|
||||
final purchaseParam = PurchaseParam(
|
||||
productDetails: product,
|
||||
applicationUserName: _appAccountToken,
|
||||
);
|
||||
final bought = await _inAppPurchase.buyConsumable(
|
||||
purchaseParam: purchaseParam,
|
||||
);
|
||||
|
||||
if (!bought) {
|
||||
_setError('Failed to initiate purchase');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
|
||||
for (final purchase in purchases) {
|
||||
_handlePurchase(purchase);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PurchaseDetails purchase) async {
|
||||
switch (purchase.status) {
|
||||
case PurchaseStatus.purchased:
|
||||
await _verifyAndComplete(purchase);
|
||||
case PurchaseStatus.canceled:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
_setState(PurchaseFlowState.idle);
|
||||
case PurchaseStatus.error:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
_setError(purchase.error?.message ?? 'Purchase failed');
|
||||
case PurchaseStatus.pending:
|
||||
_setState(PurchaseFlowState.purchasing);
|
||||
case PurchaseStatus.restored:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyAndComplete(PurchaseDetails purchase) async {
|
||||
_setState(PurchaseFlowState.verifying);
|
||||
|
||||
try {
|
||||
final request = VerifyTransactionRequest(
|
||||
productCode: _productCodeFromStoreKitId(purchase.productID),
|
||||
appStoreProductId: purchase.productID,
|
||||
transactionId: purchase.purchaseID ?? '',
|
||||
signedTransactionInfo: purchase.verificationData.serverVerificationData,
|
||||
appAccountToken: _appAccountToken,
|
||||
);
|
||||
|
||||
final response = await _paymentApi.verifyTransaction(request);
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
|
||||
_state = PurchaseFlowState.success;
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
_logger.info(
|
||||
message: 'Purchase verified and completed',
|
||||
extra: {
|
||||
'transactionId': purchase.purchaseID,
|
||||
'creditsAdded': response.creditsAdded,
|
||||
'status': response.status.name,
|
||||
},
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Purchase verification failed',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
extra: {'transactionId': purchase.purchaseID},
|
||||
);
|
||||
|
||||
if (_isRetryableError(e)) {
|
||||
_setState(PurchaseFlowState.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
if (e is ApiProblem) {
|
||||
_lastApiProblem = e;
|
||||
_lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed';
|
||||
} else {
|
||||
_lastApiProblem = null;
|
||||
_lastError = _extractErrorMessage(e);
|
||||
}
|
||||
_state = PurchaseFlowState.failed;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRetryableError(Object error) {
|
||||
if (error is ApiProblem) {
|
||||
return error.status >= 500 || error.status == 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _extractErrorMessage(Object error) {
|
||||
if (error is ApiProblem) {
|
||||
return error.detail.isNotEmpty ? error.detail : 'Verification failed';
|
||||
}
|
||||
return 'Verification failed';
|
||||
}
|
||||
|
||||
String _productCodeFromStoreKitId(String storeKitId) {
|
||||
return switch (storeKitId) {
|
||||
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
|
||||
'com.meeyao.qianwen.basic_pack' => 'basic_pack',
|
||||
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
|
||||
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
|
||||
_ => storeKitId,
|
||||
};
|
||||
}
|
||||
|
||||
void _setState(PurchaseFlowState state) {
|
||||
_state = state;
|
||||
if (state != PurchaseFlowState.failed) {
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_state = PurchaseFlowState.failed;
|
||||
_lastError = message;
|
||||
_lastApiProblem = null;
|
||||
_logger.warning(message: 'Purchase flow error', extra: {'error': message});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetState() {
|
||||
_state = PurchaseFlowState.idle;
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user