feat(payment): 优化套餐配置和支付服务

- 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml
- 优化 Apple IAP 服务和验证逻辑
- 更新套餐数据模型和协议文档
- 添加支付相关测试用例
This commit is contained in:
ZL-Q
2026-04-28 17:21:14 +08:00
parent a940f2ea47
commit 295dbc09ab
23 changed files with 285 additions and 304 deletions
@@ -14,25 +14,11 @@ 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);
AppleIapService({required ApiClient apiClient, required String userId})
: _paymentApi = ApplePaymentApi(apiClient: apiClient),
_inAppPurchase = InAppPurchase.instance,
_appAccountToken = _hashUserId(userId);
final ApplePaymentApi _paymentApi;
final InAppPurchase _inAppPurchase;
@@ -116,7 +102,7 @@ class AppleIapService with ChangeNotifier {
);
if (!bought) {
_setError('Failed to initiate purchase');
_setState(PurchaseFlowState.idle);
return false;
}
@@ -130,19 +116,62 @@ class AppleIapService with ChangeNotifier {
}
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);
try {
switch (purchase.status) {
case PurchaseStatus.purchased:
await _verifyAndComplete(purchase);
case PurchaseStatus.canceled:
await _completePurchaseSafely(purchase);
_setState(PurchaseFlowState.idle);
case PurchaseStatus.error:
final errorCode = purchase.error?.code;
final isUserCancel = errorCode == '2';
if (isUserCancel) {
await _completePurchaseSafely(purchase);
_setState(PurchaseFlowState.idle);
} else {
_logger.warning(
message: 'Purchase error',
extra: {
'errorCode': errorCode,
'errorMessage': purchase.error?.message,
},
);
await _completePurchaseSafely(purchase);
_setError(purchase.error?.message ?? 'Purchase failed');
}
case PurchaseStatus.pending:
_setState(PurchaseFlowState.purchasing);
case PurchaseStatus.restored:
await _completePurchaseSafely(purchase);
}
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to handle purchase',
error: e,
stackTrace: stackTrace,
extra: {
'productID': purchase.productID,
'purchaseID': purchase.purchaseID,
'status': purchase.status.name,
},
);
_setState(PurchaseFlowState.idle);
}
}
Future<void> _completePurchaseSafely(PurchaseDetails purchase) async {
try {
await _inAppPurchase.completePurchase(purchase);
} catch (e, stackTrace) {
_logger.warning(
message: 'completePurchase failed (likely null purchaseID): $e',
stackTrace: stackTrace,
extra: {
'productID': purchase.productID,
'purchaseID': purchase.purchaseID,
},
);
}
}
@@ -178,7 +207,12 @@ class AppleIapService with ChangeNotifier {
message: 'Purchase verification failed',
error: e,
stackTrace: stackTrace,
extra: {'transactionId': purchase.purchaseID},
extra: {
'transactionId': purchase.purchaseID,
'productID': purchase.productID,
'errorType': e.runtimeType.toString(),
'errorMessage': e.toString(),
},
);
if (_isRetryableError(e)) {
@@ -216,7 +250,7 @@ class AppleIapService with ChangeNotifier {
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.starter_pack' => 'starter_pack',
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
_ => storeKitId,
@@ -1,4 +1,4 @@
enum ProductCode { newUserPack, basicPack, popularPack, premiumPack }
enum ProductCode { newUserPack, starterPack, popularPack, premiumPack }
enum PackageType { starter, regular }
@@ -7,7 +7,6 @@ class PackageInfo {
required this.productCode,
required this.appStoreProductId,
required this.type,
required this.price,
required this.credits,
required this.isStarter,
required this.starterEligible,
@@ -17,7 +16,6 @@ class PackageInfo {
final ProductCode productCode;
final String appStoreProductId;
final PackageType type;
final double price;
final int credits;
final bool isStarter;
final bool starterEligible;
@@ -30,7 +28,6 @@ class PackageInfo {
type: json['type'] == 'starter'
? PackageType.starter
: PackageType.regular,
price: (json['price'] as num).toDouble(),
credits: json['credits'] as int,
isStarter: json['isStarter'] as bool,
starterEligible: json['starterEligible'] as bool,
@@ -41,31 +38,23 @@ class PackageInfo {
static ProductCode _parseProductCode(String code) {
return switch (code) {
'new_user_pack' => ProductCode.newUserPack,
'basic_pack' => ProductCode.basicPack,
'starter_pack' => ProductCode.starterPack,
'popular_pack' => ProductCode.popularPack,
'premium_pack' => ProductCode.premiumPack,
_ => throw ArgumentError('Unknown product code: $code'),
};
}
String get priceDisplay => '\$${price.toStringAsFixed(2)}';
}
class PackagesResult {
const PackagesResult({
required this.region,
required this.currency,
required this.packages,
});
final String region;
final String currency;
final List<PackageInfo> packages;
factory PackagesResult.fromJson(Map<String, dynamic> json) {
return PackagesResult(
region: json['region'] as String,
currency: json['currency'] as String,
packages: (json['packages'] as List<dynamic>)
.map((e) => PackageInfo.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -20,6 +20,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
required this.warning,
required this.warningContainer,
required this.onWarningContainer,
required this.incomeGreenBg,
required this.incomeGreenText,
});
final Color accentPurple;
@@ -39,6 +41,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
final Color incomeGreenBg;
final Color incomeGreenText;
@override
ThemeExtension<AppColorPalette> copyWith({
@@ -59,6 +63,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
Color? warning,
Color? warningContainer,
Color? onWarningContainer,
Color? incomeGreenBg,
Color? incomeGreenText,
}) {
return AppColorPalette(
accentPurple: accentPurple ?? this.accentPurple,
@@ -78,6 +84,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
warning: warning ?? this.warning,
warningContainer: warningContainer ?? this.warningContainer,
onWarningContainer: onWarningContainer ?? this.onWarningContainer,
incomeGreenBg: incomeGreenBg ?? this.incomeGreenBg,
incomeGreenText: incomeGreenText ?? this.incomeGreenText,
);
}
@@ -131,6 +139,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
other.onWarningContainer,
t,
)!,
incomeGreenBg: Color.lerp(incomeGreenBg, other.incomeGreenBg, t)!,
incomeGreenText: Color.lerp(incomeGreenText, other.incomeGreenText, t)!,
);
}
}