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
@@ -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",
]
+1 -30
View File
@@ -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
+2 -36
View File
@@ -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
@@ -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
@@ -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
@@ -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
-2
View File
@@ -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",
+4 -8
View File
@@ -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"
+20 -2
View File
@@ -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",
+3
View File
@@ -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()
+36 -22
View File
@@ -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,
)
@@ -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",
},
+4 -4
View File
@@ -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
@@ -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",