feat(payment): 优化套餐配置和支付服务
- 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml - 优化 Apple IAP 服务和验证逻辑 - 更新套餐数据模型和协议文档 - 添加支付相关测试用例
This commit is contained in:
@@ -23,17 +23,17 @@
|
||||
{
|
||||
"displayPrice" : "6.00",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "basic_pack_001",
|
||||
"internalID" : "starter_pack_001",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "基础信用点套餐",
|
||||
"displayName" : "基础包",
|
||||
"description" : "入门信用点套餐",
|
||||
"displayName" : "入门包",
|
||||
"locale" : "zh_CN"
|
||||
}
|
||||
],
|
||||
"productID" : "com.meeyao.qianwen.basic_pack",
|
||||
"referenceName" : "基础包",
|
||||
"productID" : "com.meeyao.qianwen.starter_pack",
|
||||
"referenceName" : "入门包",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
<string>需要将头像处理结果保存到您的相册</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要麦克风权限用于语音追问</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于开发调试时连接本地调试服务。</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_dartobservatory._tcp</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -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)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies:
|
||||
onboarding_overlay: ^3.2.3
|
||||
image_picker: ^1.1.2
|
||||
record: ^6.1.1
|
||||
flutter_timezone: ^3.0.1
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
|
||||
@@ -5,16 +5,16 @@ void main() {
|
||||
group('VerifyTransactionRequest', () {
|
||||
test('toJson includes all required fields', () {
|
||||
const request = VerifyTransactionRequest(
|
||||
productCode: 'basic_pack',
|
||||
appStoreProductId: 'com.meeyao.qianwen.basic_pack',
|
||||
productCode: 'starter_pack',
|
||||
appStoreProductId: 'com.meeyao.qianwen.starter_pack',
|
||||
transactionId: '1000000123456789',
|
||||
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['productCode'], 'basic_pack');
|
||||
expect(json['appStoreProductId'], 'com.meeyao.qianwen.basic_pack');
|
||||
expect(json['productCode'], 'starter_pack');
|
||||
expect(json['appStoreProductId'], 'com.meeyao.qianwen.starter_pack');
|
||||
expect(json['transactionId'], '1000000123456789');
|
||||
expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...');
|
||||
expect(json.containsKey('appAccountToken'), false);
|
||||
@@ -22,8 +22,8 @@ void main() {
|
||||
|
||||
test('toJson includes appAccountToken when provided', () {
|
||||
const request = VerifyTransactionRequest(
|
||||
productCode: 'basic_pack',
|
||||
appStoreProductId: 'com.meeyao.qianwen.basic_pack',
|
||||
productCode: 'starter_pack',
|
||||
appStoreProductId: 'com.meeyao.qianwen.starter_pack',
|
||||
transactionId: '1000000123456789',
|
||||
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
|
||||
appAccountToken: 'abc123def456',
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
test('parses granted status correctly', () {
|
||||
final json = {
|
||||
'status': 'granted',
|
||||
'productCode': 'basic_pack',
|
||||
'productCode': 'starter_pack',
|
||||
'transactionId': '1000000123456789',
|
||||
'creditsAdded': 100,
|
||||
'newBalance': 180,
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
final response = VerifyTransactionResponse.fromJson(json);
|
||||
|
||||
expect(response.status, VerifyTransactionStatus.granted);
|
||||
expect(response.productCode, 'basic_pack');
|
||||
expect(response.productCode, 'starter_pack');
|
||||
expect(response.transactionId, '1000000123456789');
|
||||
expect(response.creditsAdded, 100);
|
||||
expect(response.newBalance, 180);
|
||||
@@ -59,7 +59,7 @@ void main() {
|
||||
test('parses already_granted status correctly', () {
|
||||
final json = {
|
||||
'status': 'already_granted',
|
||||
'productCode': 'basic_pack',
|
||||
'productCode': 'starter_pack',
|
||||
'transactionId': '1000000123456789',
|
||||
'creditsAdded': 0,
|
||||
'newBalance': 180,
|
||||
|
||||
Reference in New Issue
Block a user