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
+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,
)