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:
ZL-Q
2026-04-28 10:45:29 +08:00
parent b453ff7345
commit 87f92987b2
58 changed files with 3741 additions and 336 deletions
@@ -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();
}
}