From 295dbc09ab8dc732fc9e0e2cd26d42847833eaaf Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Tue, 28 Apr 2026 17:21:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E4=BC=98=E5=8C=96=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E9=85=8D=E7=BD=AE=E5=92=8C=E6=94=AF=E4=BB=98=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml - 优化 Apple IAP 服务和验证逻辑 - 更新套餐数据模型和协议文档 - 添加支付相关测试用例 --- apps/ios/EryaoProducts.storekit | 10 +- apps/ios/Runner/Info.plist | 6 + .../data/services/apple_iap_service.dart | 102 +++++++++++------ .../points/data/models/package_info.dart | 15 +-- apps/lib/shared/theme/app_color_palette.dart | 10 ++ apps/pubspec.yaml | 1 + .../models/apple_purchase_models_test.dart | 18 +-- backend/src/core/config/packages/__init__.py | 12 -- backend/src/core/config/packages/registry.py | 31 +----- backend/src/core/config/packages/schema.py | 38 +------ .../core/config/static/packages/default.yaml | 30 ----- .../core/config/static/packages/mapping.yaml | 12 +- .../src/core/config/static/packages/us.yaml | 30 ----- backend/src/utils/__init__.py | 2 - backend/src/utils/paths.py | 12 +- backend/src/v1/payments/apple_verifier.py | 22 +++- backend/src/v1/payments/repository.py | 3 + backend/src/v1/payments/service.py | 58 ++++++---- .../integration/payments/test_verify_flow.py | 4 +- backend/tests/unit/payments/__init__.py | 8 +- .../unit/payments/test_payment_service.py | 59 ++++++---- docs/protocols/common/http-error-codes.md | 2 + .../common/user-points-chat-data-protocol.md | 104 +++++++++++------- 23 files changed, 285 insertions(+), 304 deletions(-) delete mode 100644 backend/src/core/config/static/packages/default.yaml delete mode 100644 backend/src/core/config/static/packages/us.yaml diff --git a/apps/ios/EryaoProducts.storekit b/apps/ios/EryaoProducts.storekit index 49c96d2..1ad1326 100644 --- a/apps/ios/EryaoProducts.storekit +++ b/apps/ios/EryaoProducts.storekit @@ -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" }, { diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index e6d3f08..6598592 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -32,6 +32,12 @@ 需要将头像处理结果保存到您的相册 NSMicrophoneUsageDescription 需要麦克风权限用于语音追问 + NSLocalNetworkUsageDescription + 用于开发调试时连接本地调试服务。 + NSBonjourServices + + _dartobservatory._tcp + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/lib/features/payments/data/services/apple_iap_service.dart b/apps/lib/features/payments/data/services/apple_iap_service.dart index adaa9dc..c938134 100644 --- a/apps/lib/features/payments/data/services/apple_iap_service.dart +++ b/apps/lib/features/payments/data/services/apple_iap_service.dart @@ -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 _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 _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, diff --git a/apps/lib/features/points/data/models/package_info.dart b/apps/lib/features/points/data/models/package_info.dart index daa2398..aea1fae 100644 --- a/apps/lib/features/points/data/models/package_info.dart +++ b/apps/lib/features/points/data/models/package_info.dart @@ -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 packages; factory PackagesResult.fromJson(Map json) { return PackagesResult( - region: json['region'] as String, - currency: json['currency'] as String, packages: (json['packages'] as List) .map((e) => PackageInfo.fromJson(e as Map)) .toList(), diff --git a/apps/lib/shared/theme/app_color_palette.dart b/apps/lib/shared/theme/app_color_palette.dart index 0d2721c..46d8c08 100644 --- a/apps/lib/shared/theme/app_color_palette.dart +++ b/apps/lib/shared/theme/app_color_palette.dart @@ -20,6 +20,8 @@ class AppColorPalette extends ThemeExtension { 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 { final Color warning; final Color warningContainer; final Color onWarningContainer; + final Color incomeGreenBg; + final Color incomeGreenText; @override ThemeExtension copyWith({ @@ -59,6 +63,8 @@ class AppColorPalette extends ThemeExtension { Color? warning, Color? warningContainer, Color? onWarningContainer, + Color? incomeGreenBg, + Color? incomeGreenText, }) { return AppColorPalette( accentPurple: accentPurple ?? this.accentPurple, @@ -78,6 +84,8 @@ class AppColorPalette extends ThemeExtension { 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 { other.onWarningContainer, t, )!, + incomeGreenBg: Color.lerp(incomeGreenBg, other.incomeGreenBg, t)!, + incomeGreenText: Color.lerp(incomeGreenText, other.incomeGreenText, t)!, ); } } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 2fa9495..b085238 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -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. diff --git a/apps/test/features/payments/data/models/apple_purchase_models_test.dart b/apps/test/features/payments/data/models/apple_purchase_models_test.dart index 205db46..fa628ff 100644 --- a/apps/test/features/payments/data/models/apple_purchase_models_test.dart +++ b/apps/test/features/payments/data/models/apple_purchase_models_test.dart @@ -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, diff --git a/backend/src/core/config/packages/__init__.py b/backend/src/core/config/packages/__init__.py index 34903f7..ab25fd6 100644 --- a/backend/src/core/config/packages/__init__.py +++ b/backend/src/core/config/packages/__init__.py @@ -1,21 +1,9 @@ -from core.config.packages.registry import ( - clear_packages_cache, - get_packages_config_for_region, -) from core.config.packages.schema import ( - PackageConfig, PackageType, ProductCode, - RegionPackagesConfig, - load_packages_config, ) __all__ = [ - "clear_packages_cache", - "get_packages_config_for_region", - "load_packages_config", - "PackageConfig", "PackageType", "ProductCode", - "RegionPackagesConfig", ] diff --git a/backend/src/core/config/packages/registry.py b/backend/src/core/config/packages/registry.py index b6cd208..d723b87 100644 --- a/backend/src/core/config/packages/registry.py +++ b/backend/src/core/config/packages/registry.py @@ -1,34 +1,5 @@ from __future__ import annotations -from core.config.packages.schema import ( - RegionPackagesConfig, - load_packages_config, -) -from utils.paths import get_default_package_config_path, get_package_config_path - - -_CONFIG_CACHE: dict[str, RegionPackagesConfig] = {} - - -def get_packages_config_for_region(country: str) -> RegionPackagesConfig: - if country in _CONFIG_CACHE: - return _CONFIG_CACHE[country] - - region_file = get_package_config_path(country) - if region_file.exists(): - config = load_packages_config(region_file) - _CONFIG_CACHE[country] = config - return config - - default_file = get_default_package_config_path() - if not default_file.exists(): - raise RuntimeError(f"No default packages config found: {default_file}") - - config = load_packages_config(default_file) - _CONFIG_CACHE[country] = config - return config - def clear_packages_cache() -> None: - global _CONFIG_CACHE - _CONFIG_CACHE = {} + pass diff --git a/backend/src/core/config/packages/schema.py b/backend/src/core/config/packages/schema.py index aa07f21..7f66bae 100644 --- a/backend/src/core/config/packages/schema.py +++ b/backend/src/core/config/packages/schema.py @@ -1,11 +1,7 @@ from __future__ import annotations from enum import Enum -from pathlib import Path -from typing import ClassVar, Literal - -import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from typing import Literal class PackageType(str, Enum): @@ -15,37 +11,7 @@ class PackageType(str, Enum): ProductCode = Literal[ "new_user_pack", - "basic_pack", + "starter_pack", "popular_pack", "premium_pack", ] - - -class PackageConfig(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - product_code: ProductCode - type: PackageType - price: float = Field(ge=0) - credits: int = Field(ge=1) - sort_order: int = Field(default=0, ge=0) - enabled: bool = Field(default=True) - - -class RegionPackagesConfig(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - region: str = Field(min_length=1, max_length=8) - currency: str = Field(min_length=1, max_length=8) - packages: list[PackageConfig] = Field(min_length=1) - - -def load_packages_config(path: Path) -> RegionPackagesConfig: - with path.open("r", encoding="utf-8") as file: - loaded: object = yaml.safe_load(file) or {} - if not isinstance(loaded, dict): - raise ValueError(f"Invalid packages config format: {path}") - try: - return RegionPackagesConfig.model_validate(loaded) - except ValidationError as exc: - raise ValueError(f"Invalid packages config data: {path}") from exc diff --git a/backend/src/core/config/static/packages/default.yaml b/backend/src/core/config/static/packages/default.yaml deleted file mode 100644 index a053c1e..0000000 --- a/backend/src/core/config/static/packages/default.yaml +++ /dev/null @@ -1,30 +0,0 @@ -region: DEFAULT -currency: USD -packages: - - product_code: new_user_pack - type: starter - price: 0.99 - credits: 60 - sort_order: 0 - enabled: true - - - product_code: basic_pack - type: regular - price: 4.99 - credits: 100 - sort_order: 10 - enabled: true - - - product_code: popular_pack - type: regular - price: 7.99 - credits: 210 - sort_order: 20 - enabled: true - - - product_code: premium_pack - type: regular - price: 12.99 - credits: 415 - sort_order: 30 - enabled: true diff --git a/backend/src/core/config/static/packages/mapping.yaml b/backend/src/core/config/static/packages/mapping.yaml index d78d77c..8faa7f6 100644 --- a/backend/src/core/config/static/packages/mapping.yaml +++ b/backend/src/core/config/static/packages/mapping.yaml @@ -3,15 +3,23 @@ product_mappings: app_store_product_id: com.meeyao.qianwen.new_user_pack credits: 60 type: starter - basic_pack: - app_store_product_id: com.meeyao.qianwen.basic_pack + sort_order: 0 + enabled: true + starter_pack: + app_store_product_id: com.meeyao.qianwen.starter_pack credits: 100 type: regular + sort_order: 10 + enabled: true popular_pack: app_store_product_id: com.meeyao.qianwen.popular_pack credits: 210 type: regular + sort_order: 20 + enabled: true premium_pack: app_store_product_id: com.meeyao.qianwen.premium_pack credits: 415 type: regular + sort_order: 30 + enabled: true diff --git a/backend/src/core/config/static/packages/us.yaml b/backend/src/core/config/static/packages/us.yaml deleted file mode 100644 index f9eaac8..0000000 --- a/backend/src/core/config/static/packages/us.yaml +++ /dev/null @@ -1,30 +0,0 @@ -region: US -currency: USD -packages: - - product_code: new_user_pack - type: starter - price: 0.99 - credits: 60 - sort_order: 0 - enabled: true - - - product_code: basic_pack - type: regular - price: 4.99 - credits: 100 - sort_order: 10 - enabled: true - - - product_code: popular_pack - type: regular - price: 7.99 - credits: 210 - sort_order: 20 - enabled: true - - - product_code: premium_pack - type: regular - price: 12.99 - credits: 415 - sort_order: 30 - enabled: true diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py index aec8823..93fc284 100644 --- a/backend/src/utils/__init__.py +++ b/backend/src/utils/__init__.py @@ -6,7 +6,6 @@ from utils.paths import ( get_gua_catalog_path, get_llm_catalog_config_path, get_notification_config_dir, - get_package_config_path, get_packages_config_dir, get_src_root, get_static_config_dir, @@ -21,7 +20,6 @@ __all__ = [ "get_gua_catalog_path", "get_llm_catalog_config_path", "get_notification_config_dir", - "get_package_config_path", "get_packages_config_dir", "get_src_root", "get_static_config_dir", diff --git a/backend/src/utils/paths.py b/backend/src/utils/paths.py index 61704a9..49e135e 100644 --- a/backend/src/utils/paths.py +++ b/backend/src/utils/paths.py @@ -19,6 +19,10 @@ def get_packages_config_dir() -> Path: return get_static_config_dir() / "packages" +def get_default_package_config_path() -> Path: + return get_packages_config_dir() / "default.yaml" + + def get_database_config_dir() -> Path: return get_static_config_dir() / "database" @@ -31,14 +35,6 @@ def get_divination_data_dir() -> Path: return get_src_root() / "core/divination/data" -def get_package_config_path(country: str) -> Path: - return get_packages_config_dir() / f"{country.lower()}.yaml" - - -def get_default_package_config_path() -> Path: - return get_packages_config_dir() / "default.yaml" - - def get_llm_catalog_config_path() -> Path: return get_database_config_dir() / "llm_catalog.yaml" diff --git a/backend/src/v1/payments/apple_verifier.py b/backend/src/v1/payments/apple_verifier.py index 184d4f0..b7ffca0 100644 --- a/backend/src/v1/payments/apple_verifier.py +++ b/backend/src/v1/payments/apple_verifier.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) _ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey) _APPLE_ROOT_CA_G3_FINGERPRINT = ( - "0e429e09b3c0da64e87f0a659a6a40ac08dde5e1b115cca0e3a8f6a5" + "b52cb02fd567e0359fe8fa4d4c41037970fe01b0" ) @@ -51,7 +51,8 @@ class AppleJwsVerifier: ) -> VerifiedTransaction | VerificationError: try: unverified_header = jwt.get_unverified_header(signed_transaction_info) - except jwt.exceptions.DecodeError: + except jwt.exceptions.DecodeError as e: + logger.error("Failed to decode JWS header: %s", str(e)) return VerificationError( code="PAYMENT_TRANSACTION_INVALID", detail="Failed to decode JWS header", @@ -117,6 +118,11 @@ class AppleJwsVerifier: bundle_id: str = str(payload.get("bundleId", "")) if bundle_id != expected_bundle_id: + logger.error( + "bundleId mismatch: expected=%s got=%s", + expected_bundle_id, + bundle_id, + ) return VerificationError( code="PAYMENT_PRODUCT_MISMATCH", detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}", @@ -124,6 +130,11 @@ class AppleJwsVerifier: product_id: str = str(payload.get("productId", "")) if product_id != expected_product_id: + logger.error( + "productId mismatch: expected=%s got=%s", + expected_product_id, + product_id, + ) return VerificationError( code="PAYMENT_PRODUCT_MISMATCH", detail=f"productId mismatch: expected={expected_product_id} got={product_id}", @@ -131,12 +142,18 @@ class AppleJwsVerifier: environment: str = str(payload.get("environment", "")) if environment not in ("Sandbox", "Production"): + logger.error("Invalid environment: %s", environment) return VerificationError( code="PAYMENT_TRANSACTION_INVALID", detail=f"Invalid environment: {environment}", ) if environment != expected_environment: + logger.error( + "Environment mismatch: expected=%s got=%s", + expected_environment, + environment, + ) return VerificationError( code="PAYMENT_ENVIRONMENT_MISMATCH", detail=f"Environment mismatch: expected={expected_environment} got={environment}", @@ -159,6 +176,7 @@ class AppleJwsVerifier: app_account_token_raw = payload.get("appAccountToken") if not transaction_id: + logger.error("Missing transactionId in payload") return VerificationError( code="PAYMENT_TRANSACTION_INVALID", detail="Missing transactionId in payload", diff --git a/backend/src/v1/payments/repository.py b/backend/src/v1/payments/repository.py index 0da10dc..bac5a65 100644 --- a/backend/src/v1/payments/repository.py +++ b/backend/src/v1/payments/repository.py @@ -83,3 +83,6 @@ class PaymentRepository: if claim is None: raise RuntimeError("Failed to upsert register bonus claim") return claim + + async def commit(self) -> None: + await self._session.commit() diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py index e9032bc..8ea32f1 100644 --- a/backend/src/v1/payments/service.py +++ b/backend/src/v1/payments/service.py @@ -35,6 +35,8 @@ class ProductMapping: app_store_product_id: str credits: int type: str + sort_order: int = 0 + enabled: bool = True _product_mappings_cache: dict[str, ProductMapping] | None = None @@ -52,14 +54,16 @@ def _load_product_mappings() -> dict[str, ProductMapping]: with mapping_path.open("r", encoding="utf-8") as f: raw: Any = yaml.safe_load(f) or {} - mappings: dict[str, ProductMapping] = {} - product_mappings: Any = raw.get("product_mappings", {}) - for code, entry in product_mappings.items(): - mappings[str(code)] = ProductMapping( - app_store_product_id=str(entry["app_store_product_id"]), - credits=int(entry["credits"]), - type=str(entry["type"]), - ) + mappings: dict[str, ProductMapping] = {} + product_mappings: Any = raw.get("product_mappings", {}) + for code, entry in product_mappings.items(): + mappings[str(code)] = ProductMapping( + app_store_product_id=str(entry["app_store_product_id"]), + credits=int(entry["credits"]), + type=str(entry["type"]), + sort_order=int(entry.get("sort_order", 0)), + enabled=bool(entry.get("enabled", True)), + ) _product_mappings_cache = mappings return mappings @@ -119,6 +123,12 @@ class PaymentService: ) if isinstance(result, VerificationError): + logger.error( + "Transaction verification failed: code=%s detail=%s transaction_id=%s", + result.code, + result.detail, + request.transaction_id, + ) status_code = 422 if result.code == "PAYMENT_TRANSACTION_REVOKED": status_code = 409 @@ -132,6 +142,11 @@ class PaymentService: verified: VerifiedTransaction = result if str(verified.transaction_id) != request.transaction_id: + logger.error( + "transactionId mismatch: request=%s verified=%s", + request.transaction_id, + verified.transaction_id, + ) raise ApiProblemError( status_code=422, detail=problem_payload( @@ -273,6 +288,15 @@ class PaymentService: transaction_record.status = "granted" transaction_record.ledger_event_id = event_id + logger.info( + "Transaction granted: user_id=%s transaction_id=%s product_code=%s credits=%d new_balance=%d", + user_id, + verified.transaction_id, + request.product_code, + credits, + new_balance, + ) + if is_starter and email_hash and normalized_email: _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( email_hash=email_hash, @@ -280,6 +304,8 @@ class PaymentService: first_user_id_snapshot=user_id, ) + await self._payment_repo.commit() + return VerifyTransactionResponse( status="granted", productCode=request.product_code, @@ -313,11 +339,6 @@ class PaymentService: return if txn.status not in ("granted",): - logger.info( - "Refund skipped: transaction %s status=%s", - transaction_id, - txn.status, - ) return user_id = txn.user_id @@ -405,6 +426,8 @@ class PaymentService: txn.status, ) + await self._payment_repo.commit() + async def handle_server_notification(self, *, signed_payload: str) -> None: if not signed_payload: logger.warning("Empty Apple server notification payload") @@ -467,13 +490,4 @@ class PaymentService: return if notification_type == "DID_RENEW" and transaction_id: - logger.info( - "Apple DID_RENEW for transaction %s, no action needed", - transaction_id, - ) return - - logger.info( - "Apple notification type=%s not handled, skipped", - notification_type, - ) diff --git a/backend/tests/integration/payments/test_verify_flow.py b/backend/tests/integration/payments/test_verify_flow.py index 66a8b1c..880f5a9 100644 --- a/backend/tests/integration/payments/test_verify_flow.py +++ b/backend/tests/integration/payments/test_verify_flow.py @@ -20,8 +20,8 @@ async def test_verify_endpoint_returns_401_without_auth() -> None: response = await client.post( "/api/v1/payments/apple/transactions/verify", json={ - "productCode": "basic_pack", - "appStoreProductId": "com.meeyao.qianwen.basic_pack", + "productCode": "starter_pack", + "appStoreProductId": "com.meeyao.qianwen.starter_pack", "transactionId": "0000000000000001", "signedTransactionInfo": "fake_jws", }, diff --git a/backend/tests/unit/payments/__init__.py b/backend/tests/unit/payments/__init__.py index 5e739bf..a96f1cd 100644 --- a/backend/tests/unit/payments/__init__.py +++ b/backend/tests/unit/payments/__init__.py @@ -22,7 +22,7 @@ class TestAppleJwsVerifierInvalidInput: result = verifier.verify_signed_transaction( "not-a-jws", expected_bundle_id="com.meeyao.qianwen", - expected_product_id="com.meeyao.qianwen.basic_pack", + expected_product_id="com.meeyao.qianwen.starter_pack", ) assert isinstance(result, VerificationError) assert result.code == "PAYMENT_TRANSACTION_INVALID" @@ -34,7 +34,7 @@ class TestAppleJwsVerifierInvalidInput: result = verifier.verify_signed_transaction( f"{h}.{p}.fake", expected_bundle_id="com.meeyao.qianwen", - expected_product_id="com.meeyao.qianwen.basic_pack", + expected_product_id="com.meeyao.qianwen.starter_pack", ) assert isinstance(result, VerificationError) assert "x5c" in result.detail @@ -45,7 +45,7 @@ class TestAppleJwsVerifierInvalidInput: result = verifier.verify_signed_transaction( f"{h}.{p}.fake", expected_bundle_id="com.meeyao.qianwen", - expected_product_id="com.meeyao.qianwen.basic_pack", + expected_product_id="com.meeyao.qianwen.starter_pack", ) assert isinstance(result, VerificationError) assert "x5c" in result.detail @@ -62,7 +62,7 @@ class TestAppleJwsVerifierInvalidInput: result = verifier.verify_signed_transaction( f"{h}.{p}.fake", expected_bundle_id="com.meeyao.qianwen", - expected_product_id="com.meeyao.qianwen.basic_pack", + expected_product_id="com.meeyao.qianwen.starter_pack", ) assert isinstance(result, VerificationError) assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail diff --git a/backend/tests/unit/payments/test_payment_service.py b/backend/tests/unit/payments/test_payment_service.py index 3fb48e7..c9a539d 100644 --- a/backend/tests/unit/payments/test_payment_service.py +++ b/backend/tests/unit/payments/test_payment_service.py @@ -30,6 +30,7 @@ class _FakePaymentRepository: self.inserted_transactions: list[AppleIapTransaction] = [] self.claim: RegisterBonusClaims | None = None self.claim_starter_pack_called: bool = False + self.commit_count = 0 async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount: return self.account @@ -59,6 +60,9 @@ class _FakePaymentRepository: self.claim.has_purchased_starter_pack = True return self.claim + async def commit(self) -> None: + self.commit_count += 1 + class _FakePointsRepository: def __init__(self) -> None: @@ -78,14 +82,17 @@ class _FakeVerifier: *, expected_bundle_id: str, expected_product_id: str, + expected_environment: str, ) -> VerifiedTransaction | VerificationError: + del signed_transaction_info, expected_bundle_id, expected_product_id + del expected_environment return self._result def _make_verified_transaction( *, transaction_id: str = "2000000123456789", - product_id: str = "com.meeyao.qianwen.basic_pack", + product_id: str = "com.meeyao.qianwen.starter_pack", environment: str = "Sandbox", ) -> VerifiedTransaction: return VerifiedTransaction( @@ -134,7 +141,7 @@ class TestPaymentServiceProductMismatch: verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( - productCode="basic_pack", + productCode="starter_pack", appStoreProductId="com.meeyao.qianwen.wrong_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", @@ -162,8 +169,8 @@ class TestPaymentServiceVerificationFailed: ), ) request = VerifyTransactionRequest( - productCode="basic_pack", - appStoreProductId="com.meeyao.qianwen.basic_pack", + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) @@ -183,8 +190,8 @@ class TestPaymentServiceAlreadyGranted: existing = AppleIapTransaction( id=uuid4(), user_id=user_id, - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000123456789", original_transaction_id="2000000123456789", environment="Sandbox", @@ -202,8 +209,8 @@ class TestPaymentServiceAlreadyGranted: verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( - productCode="basic_pack", - appStoreProductId="com.meeyao.qianwen.basic_pack", + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) @@ -222,8 +229,8 @@ class TestPaymentServiceTransactionConflict: existing = AppleIapTransaction( id=uuid4(), user_id=uuid4(), - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000123456789", original_transaction_id="2000000123456789", environment="Sandbox", @@ -241,8 +248,8 @@ class TestPaymentServiceTransactionConflict: verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( - productCode="basic_pack", - appStoreProductId="com.meeyao.qianwen.basic_pack", + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) @@ -266,8 +273,8 @@ class TestPaymentServiceSuccessfulGrant: verifier=_FakeVerifier(result=_make_verified_transaction()), ) request = VerifyTransactionRequest( - productCode="basic_pack", - appStoreProductId="com.meeyao.qianwen.basic_pack", + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", transactionId="2000000123456789", signedTransactionInfo="fake_jws", ) @@ -368,6 +375,7 @@ class _FakePaymentRepoForRefund: self._transaction = transaction self.account = account or _FakeAccountForRefund() self.inserted_transactions: list[AppleIapTransaction] = [] + self.commit_count = 0 async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: return self._transaction @@ -389,6 +397,9 @@ class _FakePaymentRepoForRefund: ) -> None: pass + async def commit(self) -> None: + self.commit_count += 1 + class TestProcessRefundUnknownTransaction: @pytest.mark.asyncio @@ -407,8 +418,8 @@ class TestProcessRefundNotGranted: txn = AppleIapTransaction( id=uuid4(), user_id=uuid4(), - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999999", original_transaction_id="2000000999999999", environment="Sandbox", @@ -435,8 +446,8 @@ class TestProcessRefundSufficientBalance: txn = AppleIapTransaction( id=uuid4(), user_id=user_id, - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999999", original_transaction_id="2000000999999999", environment="Sandbox", @@ -473,8 +484,8 @@ class TestProcessRefundInsufficientBalance: txn = AppleIapTransaction( id=uuid4(), user_id=user_id, - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999998", original_transaction_id="2000000999999998", environment="Sandbox", @@ -509,8 +520,8 @@ class TestProcessRefundIdempotency: txn = AppleIapTransaction( id=uuid4(), user_id=user_id, - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999997", original_transaction_id="2000000999999997", environment="Sandbox", @@ -539,8 +550,8 @@ class TestHandleServerNotificationRefund: txn = AppleIapTransaction( id=uuid4(), user_id=user_id, - product_code="basic_pack", - app_store_product_id="com.meeyao.qianwen.basic_pack", + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", transaction_id="2000000999999001", original_transaction_id="2000000999999001", environment="Sandbox", diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 58845a9..3121e02 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -19,6 +19,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| | `POINTS_INSUFFICIENT_BALANCE` | 402 | Not enough points to start this run | Show recharge/insufficient-points prompt | +| `POINTS_INVALID_CURSOR` | 422 | Points ledger pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page | ## Agent Session @@ -82,6 +83,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| | `NOTIFICATION_NOT_FOUND` | 404 | Notification not found or not owned by current user | Show not-found message and refresh list | +| `NOTIFICATION_INVALID_CURSOR` | 422 | Notification pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page | ## Invite diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index 8782788..0a66f6c 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -230,6 +230,49 @@ Managed by `python -m core.runtime.cli sync-notifications [flags]`: Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects. +## Points Ledger API + +### GET /api/v1/points/ledger + +Returns the authenticated user's points ledger in reverse chronological order. + +**Request:** +- Auth: Required (JWT) +- Query: + - `limit`: integer, `1..100`, default `20` + - `cursor`: optional ISO 8601 datetime returned by the previous response `nextCursor` + +**Response:** +```json +{ + "items": [ + { + "id": "9cfd5d1d-0dd8-4b30-88ce-6e4a63d22d76", + "direction": 1, + "amount": 60, + "balanceAfter": 160, + "changeType": "purchase", + "createdAt": "2026-04-28T08:30:00+00:00" + } + ], + "nextCursor": "2026-04-28T08:30:00+00:00", + "hasMore": true +} +``` + +**Fields:** +- `items`: ledger rows ordered by `createdAt desc` +- `direction`: `1` for income, `-1` for spending/deduction +- `amount`: positive points delta +- `balanceAfter`: account balance after the ledger event +- `changeType`: one of `register`, `purchase`, `consume`, `adjust`, `refund` +- `createdAt`: ISO 8601 datetime for display and pagination +- `nextCursor`: last returned row `createdAt` when `hasMore=true`; otherwise `null` +- `hasMore`: whether another page is available + +**Errors:** +- `POINTS_INVALID_CURSOR` (`422`): `cursor` is not a valid ISO 8601 datetime + ## Packages API ### GET /api/v1/points/packages @@ -243,25 +286,21 @@ Returns available purchase packages for the current user's region, including sta **Response:** ```json { - "region": "US", - "currency": "USD", "packages": [ { "productCode": "new_user_pack", + "appStoreProductId": "com.meeyao.qianwen.new_user_pack", "type": "starter", - "price": "0.99", "credits": 60, - "badge": null, "isStarter": true, "starterEligible": true, "sortOrder": 0 }, { - "productCode": "basic_pack", + "productCode": "starter_pack", + "appStoreProductId": "com.meeyao.qianwen.starter_pack", "type": "regular", - "price": "4.99", "credits": 100, - "badge": null, "isStarter": false, "starterEligible": false, "sortOrder": 10 @@ -271,51 +310,38 @@ Returns available purchase packages for the current user's region, including sta ``` **Fields:** -- `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN") -- `currency`: ISO 4217 currency code (e.g., "USD") - `packages`: List of available packages - - `productCode`: Unique product identifier (e.g., `new_user_pack`, `basic_pack`, `popular_pack`, `premium_pack`) - - `type`: "starter" (new user pack) or "regular" - - `price`: Price in the response currency (decimal string, for display reference only; actual payment uses StoreKit price) - - `credits`: Number of credits - - `badge`: Optional badge text (e.g., "Popular") - - `isStarter`: Whether this is a starter pack - - `starterEligible`: Whether user is eligible to purchase starter pack - - `sortOrder`: Display order (ascending) +- `productCode`: Unique product identifier (e.g., `new_user_pack`, `starter_pack`, `popular_pack`, `premium_pack`) +- `appStoreProductId`: Apple App Store product identifier used for StoreKit purchase +- `type`: "starter" (new user pack) or "regular" +- `credits`: Number of credits +- `isStarter`: Whether this is a starter pack +- `starterEligible`: Whether user is eligible to purchase starter pack +- `sortOrder`: Display order (ascending) **Business Logic:** -1. Determine user's region from `profile.settings.preferences.country` (default: "US") -2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`) -3. Check starter pack eligibility: - - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response - - Otherwise, include starter pack with `starterEligible: true` +1. Load package mapping from `backend/src/core/config/static/packages/mapping.yaml` +2. Check starter pack eligibility: + - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response + - Otherwise, include starter pack with `starterEligible: true` **Configuration Files:** - Path: `backend/src/core/config/static/packages/` - Format: YAML -- Example: `us.yaml` +- Example: `mapping.yaml` ```yaml -region: US -currency: USD -packages: - - product_code: new_user_pack - type: starter - price: "0.99" +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack credits: 60 - badge: null + type: starter sort_order: 0 enabled: true - - product_code: basic_pack - type: regular - price: "4.99" + starter_pack: + app_store_product_id: com.meeyao.qianwen.starter_pack credits: 100 - badge: null + type: regular sort_order: 10 enabled: true ``` - -**Country/Region Codes:** -- Uses ISO 3166-1 alpha-2 standard -- Default: `US` (United States) -- Examples: `CN` (China), `TW` (Taiwan), `HK` (Hong Kong), `JP` (Japan)