feat: 实现 iOS Apple Pay 内购支付功能

前端:
- 集成 in_app_purchase 插件,实现 IAP 支付流程
- 添加支付模块 (payments/) 处理产品获取、购买、验证
- 积分中心页面集成 Apple Pay 购买入口
- 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面
- 修复欢迎引导页滚动检测阈值问题
- 修复解卦结果页 iOS 侧滑返回手势被阻止的问题
- 邀请码绑定按钮临时禁用(待后端实现)

后端:
- 新增 apple_iap_transactions 表记录交易
- 实现 Apple 服务器端验证 (App Store Server API)
- 支付成功后自动发放积分
- 支持 Sandbox/Production 环境切换
- 添加退款处理和交易状态机

协议:
- 更新积分流水协议,支持 purchase/refund 类型
- 新增 PAYMENT_* 错误码
This commit is contained in:
ZL-Q
2026-04-28 10:45:29 +08:00
parent b453ff7345
commit 87f92987b2
58 changed files with 3741 additions and 336 deletions
@@ -356,7 +356,7 @@ class AgentScopeRunner:
) -> TrackingChatModel:
generate_kwargs: dict[str, Any] = {
"timeout": stage_config.llm_config.timeout_seconds,
"extra_body": {"enable_thinking": False},
"extra_body": {"thinking": {"type": "disabled"}},
}
if stage_config.llm_config.temperature is not None:
generate_kwargs["temperature"] = stage_config.llm_config.temperature
+13
View File
@@ -228,6 +228,18 @@ class PointsPolicySettings(BaseModel):
return self
class AppleIapSettings(BaseModel):
bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1)
root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer"
jws_x5c_cert_url: str = "https://api.storekit.itunes.apple.com/v1/verificationKeys"
server_api_issuer_id: str | None = None
server_api_key_id: str | None = None
server_api_private_key: SecretStr | None = None
sandbox_tester_email: str | None = None
sandbox_tester_password: SecretStr | None = None
server_notifications_url: str | None = None
def _resolve_env_file() -> str:
current = Path(__file__).resolve()
for parent in [current, *current.parents]:
@@ -271,6 +283,7 @@ class Settings(BaseSettings):
taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings)
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings)
feedback_report: FeedbackReportSettings = Field(
default_factory=FeedbackReportSettings
)
@@ -1,24 +1,24 @@
agents:
- agent_type: router
llm_model_code: qwen3.5-flash
status: active
config:
temperature: 0.7
max_tokens: null
timeout_seconds: 30
context_messages:
mode: day
count: 2
enabled_tools: []
- agent_type: router
llm_model_code: qwen3.5-flash
status: active
config:
temperature: 0.7
max_tokens: null
timeout_seconds: 30
context_messages:
mode: day
count: 2
enabled_tools: []
- agent_type: worker
llm_model_code: deepseek-chat
status: active
config:
temperature: 0.7
max_tokens: 2048
timeout_seconds: 120
context_messages:
mode: number
count: 20
enabled_tools: []
- agent_type: worker
llm_model_code: deepseek-v4-flash
status: active
config:
temperature: 0.7
max_tokens: 2048
timeout_seconds: 120
context_messages:
mode: number
count: 20
enabled_tools: []
@@ -0,0 +1,17 @@
product_mappings:
new_user_pack:
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
credits: 100
type: regular
popular_pack:
app_store_product_id: com.meeyao.qianwen.popular_pack
credits: 210
type: regular
premium_pack:
app_store_product_id: com.meeyao.qianwen.premium_pack
credits: 415
type: regular
+2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from .agent_chat_message import AgentChatMessage
from .agent_chat_session import AgentChatSession
from .anonymous_session_snapshot import AnonymousSessionSnapshot
from .apple_iap_transaction import AppleIapTransaction
from .auth_user import AuthUser
from .invite_code import InviteCode
from .llm import Llm
@@ -20,6 +21,7 @@ __all__ = [
"AgentChatMessage",
"AgentChatSession",
"AnonymousSessionSnapshot",
"AppleIapTransaction",
"AuthUser",
"InviteCode",
"Llm",
@@ -0,0 +1,91 @@
from __future__ import annotations
import uuid
from sqlalchemy import (
BigInteger,
CheckConstraint,
Index,
JSON,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class AppleIapTransaction(TimestampMixin, Base):
__tablename__ = "apple_iap_transactions"
__table_args__ = (
CheckConstraint(
"environment in ('Sandbox', 'Production')",
name="ck_apple_iap_transactions_environment",
),
CheckConstraint(
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
name="ck_apple_iap_transactions_status",
),
UniqueConstraint(
"transaction_id", name="uq_apple_iap_transactions_transaction_id"
),
UniqueConstraint(
"ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"
),
Index(
"ix_apple_iap_transactions_user_created_at",
"user_id",
text("created_at DESC"),
),
Index(
"ix_apple_iap_transactions_status_updated_at",
"status",
text("updated_at DESC"),
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
)
product_code: Mapped[str] = mapped_column(String(32), nullable=False)
app_store_product_id: Mapped[str] = mapped_column(String(128), nullable=False)
transaction_id: Mapped[str] = mapped_column(String(64), nullable=False)
original_transaction_id: Mapped[str | None] = mapped_column(
String(64), nullable=True
)
web_order_line_item_id: Mapped[str | None] = mapped_column(
String(64), nullable=True
)
environment: Mapped[str] = mapped_column(String(16), nullable=False)
bundle_id: Mapped[str] = mapped_column(String(128), nullable=False)
app_account_token: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
purchase_date: Mapped[str] = mapped_column(
Text,
nullable=False,
)
revocation_date: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(24), nullable=False)
credits: Mapped[int] = mapped_column(BigInteger, nullable=False)
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
price_milliunits: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
ledger_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
signed_transaction_info: Mapped[str] = mapped_column(Text, nullable=False)
apple_payload_json: Mapped[dict[str, object]] = mapped_column(
"apple_payload",
JSON().with_variant(JSONB, "postgresql"),
nullable=False,
server_default=text("'{}'::jsonb"),
default=dict,
)
failure_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+2 -2
View File
@@ -36,11 +36,11 @@ class PointsAuditLedger(TimestampMixin, Base):
name="ck_points_audit_ledger_balance_after_non_negative",
),
CheckConstraint(
"change_type in ('register', 'consume', 'grant', 'adjust')",
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
name="ck_points_audit_ledger_change_type",
),
CheckConstraint(
"biz_type is null or biz_type = 'chat'",
"biz_type is null or biz_type in ('chat', 'payment')",
name="ck_points_audit_ledger_biz_type",
),
CheckConstraint(
+25 -8
View File
@@ -29,21 +29,22 @@ class PointsLedger(TimestampMixin, Base):
"balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"
),
CheckConstraint(
"change_type in ('register', 'consume', 'grant', 'adjust')",
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
name="ck_points_ledger_change_type",
),
CheckConstraint(
"biz_type is null or biz_type = 'chat'",
"biz_type is null or biz_type in ('chat', 'payment')",
name="ck_points_ledger_biz_type",
),
CheckConstraint(
"((change_type = 'register' and biz_type is null and biz_id is null) or "
"(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))",
"((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or "
"(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or "
"(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))",
name="ck_points_ledger_biz_binding",
),
CheckConstraint(
"((change_type in ('register', 'grant') and direction = 1) or "
"(change_type = 'consume' and direction = -1) or "
"((change_type in ('register', 'purchase') and direction = 1) or "
"(change_type in ('consume', 'refund') and direction = -1) or "
"(change_type = 'adjust' and direction in (1, -1)))",
name="ck_points_ledger_direction_by_change_type",
),
@@ -72,10 +73,26 @@ class PointsLedger(TimestampMixin, Base):
),
CheckConstraint(
"(change_type <> 'adjust' or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and "
"coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))",
"(metadata ? 'ext') and (metadata->'ext' ? 'reason') and "
"coalesce(metadata #>> '{ext,reason}', '') <> ''))",
name="ck_points_ledger_metadata_adjust_shape",
),
CheckConstraint(
"(change_type not in ('purchase', 'refund') or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and "
"(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and "
"coalesce(metadata #>> '{ext,source}', '') <> '' and "
"coalesce(metadata #>> '{ext,platform}', '') <> '' and "
"coalesce(metadata #>> '{ext,product_code}', '') <> '' and "
"coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))",
name="ck_points_ledger_metadata_payment_shape",
),
CheckConstraint(
"(change_type <> 'refund' or ("
"(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and "
"coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))",
name="ck_points_ledger_metadata_refund_shape",
),
UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"),
Index("ix_points_ledger_user_created_at", "user_id", text("created_at DESC")),
Index("ix_points_ledger_biz_type_biz_id", "biz_type", "biz_id"),
+30 -20
View File
@@ -43,26 +43,26 @@ class ConsumeLedgerMetadata(PointsLedgerMetadataBase):
charge: PointsChargeSnapshot
class GrantLedgerMetadata(PointsLedgerMetadataBase):
charge: PointsChargeSnapshot | None = None
class AdjustLedgerMetadata(PointsLedgerMetadataBase):
charge: PointsChargeSnapshot | None = None
@model_validator(mode="after")
def validate_ticket(self) -> "AdjustLedgerMetadata":
ticket_id = self.ext.get("ticket_id")
if not isinstance(ticket_id, str) or not ticket_id.strip():
raise ValueError("ext.ticket_id is required for adjust")
def validate_reason(self) -> "AdjustLedgerMetadata":
reason = self.ext.get("reason")
if not isinstance(reason, str) or not reason.strip():
raise ValueError("ext.reason is required for adjust")
return self
class PurchaseLedgerMetadata(PointsLedgerMetadataBase):
pass
PointsLedgerMetadata = (
RegisterLedgerMetadata
| ConsumeLedgerMetadata
| GrantLedgerMetadata
| AdjustLedgerMetadata
| PurchaseLedgerMetadata
)
@@ -75,8 +75,6 @@ def parse_points_ledger_metadata(
return RegisterLedgerMetadata.model_validate(payload)
if change_type == PointsChangeType.CONSUME:
return ConsumeLedgerMetadata.model_validate(payload)
if change_type == PointsChangeType.GRANT:
return GrantLedgerMetadata.model_validate(payload)
return AdjustLedgerMetadata.model_validate(payload)
@@ -114,17 +112,29 @@ class ApplyPointsChangeCommand(BaseModel):
raise ValueError("consume must use direction=-1 and chat binding")
return self
if self.change_type == PointsChangeType.GRANT:
if (
self.direction != 1
or self.biz_type != PointsBizType.CHAT
or self.biz_id is None
):
raise ValueError("grant must use direction=1 and chat binding")
if self.change_type == PointsChangeType.ADJUST:
if self.biz_type is not None or self.biz_id is not None:
raise ValueError("adjust must not have biz binding")
return self
if self.change_type == PointsChangeType.PURCHASE:
if (
self.direction != 1
or self.biz_type != PointsBizType.PAYMENT
or self.biz_id is None
):
raise ValueError("purchase must use direction=1 and payment binding")
return self
if self.change_type == PointsChangeType.REFUND:
if (
self.direction != -1
or self.biz_type != PointsBizType.PAYMENT
or self.biz_id is None
):
raise ValueError("refund must use direction=-1 and payment binding")
return self
if self.biz_type != PointsBizType.CHAT or self.biz_id is None:
raise ValueError("adjust must use chat binding")
return self
+3 -1
View File
@@ -69,12 +69,14 @@ class SessionType(str, Enum):
class PointsChangeType(str, Enum):
REGISTER = "register"
CONSUME = "consume"
GRANT = "grant"
ADJUST = "adjust"
PURCHASE = "purchase"
REFUND = "refund"
class PointsBizType(str, Enum):
CHAT = "chat"
PAYMENT = "payment"
class PointsOperatorType(str, Enum):
View File
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import base64
import hashlib
import logging
from dataclasses import dataclass
from typing import Any
import jwt
from cryptography.x509 import load_der_x509_certificate
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
logger = logging.getLogger(__name__)
_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey)
_APPLE_ROOT_CA_G3_FINGERPRINT = (
"0e429e09b3c0da64e87f0a659a6a40ac08dde5e1b115cca0e3a8f6a5"
)
@dataclass(frozen=True)
class VerifiedTransaction:
transaction_id: str
original_transaction_id: str
web_order_line_item_id: str | None
bundle_id: str
product_id: str
purchase_date: int
revocation_date: int | None
environment: str
app_account_token: str | None
raw_payload: dict[str, Any]
@dataclass(frozen=True)
class VerificationError:
code: str
detail: str
class AppleJwsVerifier:
def verify_signed_transaction(
self,
signed_transaction_info: str,
*,
expected_bundle_id: str,
expected_product_id: str,
expected_environment: str,
) -> VerifiedTransaction | VerificationError:
try:
unverified_header = jwt.get_unverified_header(signed_transaction_info)
except jwt.exceptions.DecodeError:
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="Failed to decode JWS header",
)
x5c_raw = unverified_header.get("x5c")
if not x5c_raw or not isinstance(x5c_raw, list) or len(x5c_raw) < 3:
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="JWS x5c chain missing or incomplete",
)
x5c: list[str] = x5c_raw
root_der = base64.b64decode(x5c[-1])
root_fingerprint = hashlib.sha1(root_der).hexdigest().lower()
if root_fingerprint != _APPLE_ROOT_CA_G3_FINGERPRINT:
logger.warning(
"Apple root cert fingerprint mismatch: expected=%s got=%s",
_APPLE_ROOT_CA_G3_FINGERPRINT,
root_fingerprint,
)
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="Apple root certificate fingerprint mismatch",
)
chain_error = self._verify_cert_chain_issuer_subject(x5c)
if chain_error is not None:
return chain_error
cert_der = base64.b64decode(x5c[0])
cert = load_der_x509_certificate(cert_der)
public_key = cert.public_key()
if not isinstance(public_key, _ALLOWED_KEY_TYPES):
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="Unsupported certificate key type",
)
try:
payload: dict[str, Any] = jwt.decode(
signed_transaction_info,
public_key,
algorithms=["ES256"],
options={
"verify_exp": False,
"verify_aud": False,
"verify_iss": False,
"verify_sub": False,
},
)
except jwt.exceptions.InvalidSignatureError:
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="JWS signature verification failed",
)
except jwt.exceptions.DecodeError:
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="JWS payload decode failed",
)
bundle_id: str = str(payload.get("bundleId", ""))
if bundle_id != expected_bundle_id:
return VerificationError(
code="PAYMENT_PRODUCT_MISMATCH",
detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}",
)
product_id: str = str(payload.get("productId", ""))
if product_id != expected_product_id:
return VerificationError(
code="PAYMENT_PRODUCT_MISMATCH",
detail=f"productId mismatch: expected={expected_product_id} got={product_id}",
)
environment: str = str(payload.get("environment", ""))
if environment not in ("Sandbox", "Production"):
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail=f"Invalid environment: {environment}",
)
if environment != expected_environment:
return VerificationError(
code="PAYMENT_ENVIRONMENT_MISMATCH",
detail=f"Environment mismatch: expected={expected_environment} got={environment}",
)
revocation_date_raw = payload.get("revocationDate")
revocation_date: int | None = (
int(revocation_date_raw) if revocation_date_raw is not None else None
)
if revocation_date is not None and revocation_date > 0:
return VerificationError(
code="PAYMENT_TRANSACTION_REVOKED",
detail="Transaction has been revoked",
)
transaction_id = str(payload.get("transactionId", ""))
original_transaction_id = str(payload.get("originalTransactionId", ""))
web_order_line_item_id_raw = payload.get("webOrderLineItemId")
purchase_date = int(payload.get("purchaseDate", 0))
app_account_token_raw = payload.get("appAccountToken")
if not transaction_id:
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="Missing transactionId in payload",
)
return VerifiedTransaction(
transaction_id=transaction_id,
original_transaction_id=original_transaction_id,
web_order_line_item_id=(
str(web_order_line_item_id_raw) if web_order_line_item_id_raw else None
),
bundle_id=bundle_id,
product_id=product_id,
purchase_date=purchase_date,
revocation_date=revocation_date,
environment=environment,
app_account_token=(
str(app_account_token_raw) if app_account_token_raw else None
),
raw_payload=payload,
)
def _verify_cert_chain_issuer_subject(
self, x5c: list[str]
) -> VerificationError | None:
certs = []
for i, b64_der in enumerate(x5c):
der = base64.b64decode(b64_der)
certs.append(load_der_x509_certificate(der))
for i in range(len(certs) - 1):
child = certs[i]
parent = certs[i + 1]
if child.issuer != parent.subject:
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail=f"Certificate chain issuer/subject mismatch at index {i}",
)
return None
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_db
from v1.payments.apple_verifier import AppleJwsVerifier
from v1.payments.repository import PaymentRepository
from v1.payments.service import PaymentService
from v1.points.repository import PointsRepository
def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentService:
payment_repo = PaymentRepository(session)
points_repo = PointsRepository(session)
verifier = AppleJwsVerifier()
return PaymentService(
payment_repo=payment_repo,
points_repo=points_repo,
verifier=verifier,
)
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from models.apple_iap_transaction import AppleIapTransaction
from models.register_bonus_claims import RegisterBonusClaims
from models.user_points import UserPoints
class PaymentRepository:
def __init__(self, session: AsyncSession) -> None:
self._session: AsyncSession = session
async def get_or_create_user_points_for_update(
self, *, user_id: UUID
) -> UserPoints:
insert_stmt = (
insert(UserPoints)
.values(user_id=user_id)
.on_conflict_do_nothing(index_elements=[UserPoints.user_id])
)
_ = await self._session.execute(insert_stmt)
stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update()
return (await self._session.execute(stmt)).scalar_one()
async def get_user_points_for_update(self, *, user_id: UUID) -> UserPoints | None:
stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update()
return (await self._session.execute(stmt)).scalar_one_or_none()
async def get_transaction_by_transaction_id(
self, *, transaction_id: str
) -> AppleIapTransaction | None:
stmt = select(AppleIapTransaction).where(
AppleIapTransaction.transaction_id == transaction_id
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None:
self._session.add(transaction)
await self._session.flush()
async def get_register_bonus_claim(
self, *, email_hash: str
) -> RegisterBonusClaims | None:
stmt = (
select(RegisterBonusClaims)
.where(RegisterBonusClaims.email_hash == email_hash)
.limit(1)
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def upsert_register_bonus_claim_for_starter_pack(
self,
*,
email_hash: str,
user_email_snapshot: str,
first_user_id_snapshot: UUID,
) -> RegisterBonusClaims:
claim = await self.get_register_bonus_claim(email_hash=email_hash)
if claim is not None:
claim.has_purchased_starter_pack = True
await self._session.flush()
return claim
insert_stmt = (
insert(RegisterBonusClaims)
.values(
email_hash=email_hash,
user_email_snapshot=user_email_snapshot,
first_user_id_snapshot=first_user_id_snapshot,
grant_event_id=f"starter_pack_purchase:{email_hash[:16]}",
has_purchased_starter_pack=True,
)
.on_conflict_do_nothing(index_elements=[RegisterBonusClaims.email_hash])
)
_ = await self._session.execute(insert_stmt)
claim = await self.get_register_bonus_claim(email_hash=email_hash)
if claim is None:
raise RuntimeError("Failed to upsert register bonus claim")
return claim
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from core.auth.models import CurrentUser
from v1.payments.dependencies import get_payment_service
from v1.payments.schemas import (
AppleServerNotificationRequest,
VerifyTransactionRequest,
VerifyTransactionResponse,
)
from v1.payments.service import PaymentService
from v1.users.dependencies import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/payments", tags=["payments"])
@router.post(
"/apple/transactions/verify",
response_model=VerifyTransactionResponse,
)
async def verify_apple_transaction(
request: VerifyTransactionRequest,
service: Annotated[PaymentService, Depends(get_payment_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> VerifyTransactionResponse:
return await service.verify_and_grant(
user_id=current_user.id,
user_email=current_user.email or "",
request=request,
)
@router.post("/apple/notifications", status_code=200)
async def handle_apple_server_notification(
request: AppleServerNotificationRequest,
service: Annotated[PaymentService, Depends(get_payment_service)],
) -> Response:
await service.handle_server_notification(signed_payload=request.signed_payload)
return Response(status_code=200)
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class VerifyTransactionRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
product_code: str = Field(alias="productCode", min_length=1, max_length=32)
app_store_product_id: str = Field(
alias="appStoreProductId", min_length=1, max_length=128
)
transaction_id: str = Field(alias="transactionId", min_length=1, max_length=64)
signed_transaction_info: str = Field(
alias="signedTransactionInfo", min_length=1
)
app_account_token: UUID | None = Field(
alias="appAccountToken", default=None
)
class VerifyTransactionResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
status: Literal["granted", "already_granted"]
product_code: str = Field(alias="productCode")
transaction_id: str = Field(alias="transactionId")
credits_added: int = Field(alias="creditsAdded", ge=0)
new_balance: int = Field(alias="newBalance", ge=0)
ledger_event_id: str = Field(alias="ledgerEventId")
class AppleNotificationPayload(BaseModel):
model_config = ConfigDict(extra="allow")
notification_type: str = Field(alias="notificationType", default="")
subtype: str | None = Field(alias="subtype", default=None)
signed_payload: str = Field(alias="signedPayload", default="")
class AppleServerNotificationRequest(BaseModel):
model_config = ConfigDict(extra="allow")
signed_payload: str = Field(alias="signedPayload", default="")
+479
View File
@@ -0,0 +1,479 @@
from __future__ import annotations
import hashlib
import hmac
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
import yaml
from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload
from models.apple_iap_transaction import AppleIapTransaction
from schemas.domain.points import (
ApplyPointsChangeCommand,
PurchaseLedgerMetadata,
)
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from v1.payments.apple_verifier import (
AppleJwsVerifier,
VerificationError,
VerifiedTransaction,
)
from v1.payments.repository import PaymentRepository
from v1.payments.schemas import VerifyTransactionRequest, VerifyTransactionResponse
from v1.points.repository import PointsRepository
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProductMapping:
app_store_product_id: str
credits: int
type: str
_product_mappings_cache: dict[str, ProductMapping] | None = None
def _load_product_mappings() -> dict[str, ProductMapping]:
global _product_mappings_cache
if _product_mappings_cache is not None:
return _product_mappings_cache
mapping_path = (
Path(__file__).parent.parent.parent
/ "core/config/static/packages/mapping.yaml"
)
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"]),
)
_product_mappings_cache = mappings
return mappings
def clear_product_mappings_cache() -> None:
global _product_mappings_cache
_product_mappings_cache = None
class PaymentService:
def __init__(
self,
*,
payment_repo: PaymentRepository,
points_repo: PointsRepository,
verifier: AppleJwsVerifier,
) -> None:
self._payment_repo: PaymentRepository = payment_repo
self._points_repo: PointsRepository = points_repo
self._verifier: AppleJwsVerifier = verifier
async def verify_and_grant(
self,
*,
user_id: UUID,
user_email: str,
request: VerifyTransactionRequest,
) -> VerifyTransactionResponse:
mappings = _load_product_mappings()
product_mapping = mappings.get(request.product_code)
if product_mapping is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PAYMENT_PRODUCT_NOT_FOUND",
detail=f"Product not found: {request.product_code}",
),
)
if request.app_store_product_id != product_mapping.app_store_product_id:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="PAYMENT_PRODUCT_MISMATCH",
detail="appStoreProductId does not match backend mapping",
),
)
expected_bundle_id = config.apple_iap.bundle_id
expected_environment = "Sandbox" if config.runtime.environment != "prod" else "Production"
result = self._verifier.verify_signed_transaction(
request.signed_transaction_info,
expected_bundle_id=expected_bundle_id,
expected_product_id=product_mapping.app_store_product_id,
expected_environment=expected_environment,
)
if isinstance(result, VerificationError):
status_code = 422
if result.code == "PAYMENT_TRANSACTION_REVOKED":
status_code = 409
elif result.code == "PAYMENT_PRODUCT_MISMATCH":
status_code = 422
raise ApiProblemError(
status_code=status_code,
detail=problem_payload(code=result.code, detail=result.detail),
)
verified: VerifiedTransaction = result
if str(verified.transaction_id) != request.transaction_id:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="PAYMENT_TRANSACTION_INVALID",
detail="transactionId does not match verified payload",
),
)
existing = await self._payment_repo.get_transaction_by_transaction_id(
transaction_id=verified.transaction_id
)
if existing is not None:
if existing.user_id == user_id and existing.status == "granted":
account = await self._payment_repo.get_or_create_user_points_for_update(
user_id=user_id
)
return VerifyTransactionResponse(
status="already_granted",
productCode=request.product_code,
transactionId=verified.transaction_id,
creditsAdded=0,
newBalance=int(account.balance),
ledgerEventId=existing.ledger_event_id or "",
)
if existing.user_id != user_id:
raise ApiProblemError(
status_code=409,
detail=problem_payload(
code="PAYMENT_TRANSACTION_CONFLICT",
detail="Transaction belongs to another user",
),
)
if existing.status in ("refunded", "refunded_insufficient", "revoked"):
raise ApiProblemError(
status_code=409,
detail=problem_payload(
code="PAYMENT_TRANSACTION_REVOKED",
detail="Transaction has been refunded or revoked",
),
)
is_starter = product_mapping.type == "starter"
normalized_email = user_email.strip().lower()
email_hash = (
self._build_email_hash(normalized_email) if normalized_email else None
)
if is_starter:
if not email_hash:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="PAYMENT_STARTER_PACK_INELIGIBLE",
detail="Email required for starter pack purchase",
),
)
claim = await self._payment_repo.get_register_bonus_claim(
email_hash=email_hash
)
if claim is not None and claim.has_purchased_starter_pack:
raise ApiProblemError(
status_code=409,
detail=problem_payload(
code="PAYMENT_STARTER_PACK_INELIGIBLE",
detail="Starter pack already purchased for this email",
),
)
transaction_record = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
product_code=request.product_code,
app_store_product_id=product_mapping.app_store_product_id,
transaction_id=verified.transaction_id,
original_transaction_id=verified.original_transaction_id,
web_order_line_item_id=verified.web_order_line_item_id,
environment=verified.environment,
bundle_id=verified.bundle_id,
app_account_token=request.app_account_token,
purchase_date=str(verified.purchase_date),
revocation_date=(
str(verified.revocation_date) if verified.revocation_date else None
),
status="verified",
credits=product_mapping.credits,
currency=None,
price_milliunits=None,
signed_transaction_info=request.signed_transaction_info,
apple_payload_json=verified.raw_payload,
)
await self._payment_repo.insert_transaction(transaction=transaction_record)
account = await self._payment_repo.get_or_create_user_points_for_update(
user_id=user_id
)
credits = product_mapping.credits
event_id = f"payment.apple_iap:{verified.transaction_id}"
balance = int(account.balance)
new_balance = balance + credits
account.balance = new_balance
account.lifetime_earned = int(account.lifetime_earned) + credits
account.version = int(account.version) + 1
metadata = PurchaseLedgerMetadata(
operator_type=PointsOperatorType.SYSTEM,
run_id=event_id,
ext={
"source": "apple_iap",
"platform": "ios",
"product_code": request.product_code,
"app_store_product_id": product_mapping.app_store_product_id,
"transaction_id": verified.transaction_id,
"original_transaction_id": verified.original_transaction_id,
"environment": verified.environment,
"apple_iap_transaction_id": str(transaction_record.id),
},
)
ledger_command = ApplyPointsChangeCommand(
user_id=user_id,
change_type=PointsChangeType.PURCHASE,
biz_type=PointsBizType.PAYMENT,
biz_id=transaction_record.id,
event_id=event_id,
amount=credits,
direction=1,
operator_id=None,
metadata=metadata,
)
await self._points_repo.append_ledger(
command=ledger_command,
balance_after=new_balance,
)
transaction_record.status = "granted"
transaction_record.ledger_event_id = event_id
if is_starter and email_hash and normalized_email:
_ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack(
email_hash=email_hash,
user_email_snapshot=normalized_email,
first_user_id_snapshot=user_id,
)
return VerifyTransactionResponse(
status="granted",
productCode=request.product_code,
transactionId=verified.transaction_id,
creditsAdded=credits,
newBalance=new_balance,
ledgerEventId=event_id,
)
@staticmethod
def _build_email_hash(normalized_email: str) -> str:
key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip()
digest = hmac.new(
key=key.encode("utf-8"),
msg=normalized_email.encode("utf-8"),
digestmod=hashlib.sha256,
)
return digest.hexdigest()
async def process_refund(
self,
*,
transaction_id: str,
refund_reason: str = "CUSTOMER_REQUEST",
) -> None:
txn = await self._payment_repo.get_transaction_by_transaction_id(
transaction_id=transaction_id
)
if txn is None:
logger.warning("Refund requested for unknown transaction: %s", transaction_id)
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
credits = txn.credits
account = await self._payment_repo.get_user_points_for_update(user_id=user_id)
if account is None:
logger.warning(
"Refund failed: no user_points for user %s on transaction %s",
user_id,
transaction_id,
)
txn.status = "failed"
txn.failure_code = "USER_POINTS_NOT_FOUND"
return
balance = int(account.balance)
if balance < credits:
refund_amount = balance
txn.status = "refunded_insufficient"
txn.failure_code = "INSUFFICIENT_BALANCE"
logger.warning(
"Refund insufficient balance: user=%s credits=%d balance=%d txn=%s",
user_id,
credits,
balance,
transaction_id,
)
else:
refund_amount = credits
txn.status = "refunded"
new_balance = balance - refund_amount
account.balance = new_balance
account.lifetime_earned = int(account.lifetime_earned) - refund_amount
account.version = int(account.version) + 1
refund_event_id = f"refund.apple_iap:{transaction_id}"
original_event_id = txn.ledger_event_id or f"payment.apple_iap:{transaction_id}"
metadata = PurchaseLedgerMetadata(
operator_type=PointsOperatorType.SYSTEM,
run_id=refund_event_id,
ext={
"source": "apple_iap",
"platform": "ios",
"product_code": txn.product_code,
"app_store_product_id": txn.app_store_product_id,
"transaction_id": transaction_id,
"original_transaction_id": txn.original_transaction_id or "",
"environment": txn.environment,
"apple_iap_transaction_id": str(txn.id),
"original_event_id": original_event_id,
"refund_reason": refund_reason,
"overdue_amount": credits - refund_amount,
},
)
if refund_amount > 0:
ledger_command = ApplyPointsChangeCommand(
user_id=user_id,
change_type=PointsChangeType.REFUND,
biz_type=PointsBizType.PAYMENT,
biz_id=txn.id,
event_id=refund_event_id,
amount=refund_amount,
direction=-1,
operator_id=None,
metadata=metadata,
)
await self._points_repo.append_ledger(
command=ledger_command,
balance_after=new_balance,
)
txn.ledger_event_id = refund_event_id
logger.info(
"Refund processed: txn=%s user=%s refund_amount=%d new_balance=%d status=%s",
transaction_id,
user_id,
refund_amount,
new_balance,
txn.status,
)
async def handle_server_notification(self, *, signed_payload: str) -> None:
if not signed_payload:
logger.warning("Empty Apple server notification payload")
return
try:
import jwt as pyjwt
parts = signed_payload.split(".")
if len(parts) < 2:
logger.warning("Malformed Apple notification signed_payload")
return
payload_bytes = parts[1] + "=" * (-len(parts[1]) % 4)
import base64
decoded = base64.urlsafe_b64decode(payload_bytes)
import json
notification_data: Any = json.loads(decoded)
except Exception:
logger.exception("Failed to decode Apple server notification payload")
return
notification_type = str(notification_data.get("notificationType", ""))
subtype = notification_data.get("subtype")
signed_transaction = notification_data.get("data", {}).get(
"signedTransactionInfo", ""
)
transaction_id: str | None = None
if signed_transaction:
try:
txn_parts = signed_transaction.split(".")
if len(txn_parts) >= 2:
txn_payload_bytes = txn_parts[1] + "=" * (-len(txn_parts[1]) % 4)
txn_decoded = base64.urlsafe_b64decode(txn_payload_bytes)
txn_data: Any = json.loads(txn_decoded)
transaction_id = str(txn_data.get("transactionId", ""))
except Exception:
logger.exception("Failed to decode signed transaction from notification")
logger.info(
"Apple notification received: type=%s subtype=%s transaction_id=%s",
notification_type,
subtype,
transaction_id,
)
refund_types = {"REFUND", "REVOKE", "DID_FAIL_TO_RENEW"}
if notification_type in refund_types and transaction_id:
refund_reason = notification_type
if subtype:
refund_reason = f"{notification_type}:{subtype}"
await self.process_refund(
transaction_id=transaction_id,
refund_reason=refund_reason,
)
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,
)
+1
View File
@@ -44,6 +44,7 @@ async def get_available_packages(
packages=[
PackageInfo(
productCode=pkg.product_code,
appStoreProductId=pkg.app_store_product_id,
type=pkg.type.value,
price=pkg.price,
credits=pkg.credits,
+3
View File
@@ -19,6 +19,9 @@ class PackageInfo(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
product_code: str = Field(alias="productCode", min_length=1, max_length=128)
app_store_product_id: str = Field(
alias="appStoreProductId", min_length=1, max_length=256
)
type: Literal["starter", "regular"]
price: float = Field(ge=0)
credits: int = Field(ge=1)
+8
View File
@@ -23,6 +23,7 @@ from schemas.domain.points import (
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from schemas.domain.points import ApplyPointsChangeCommand
from schemas.shared.user import parse_profile_settings
from v1.payments.service import _load_product_mappings
from v1.points.repository import PointsRepository
if TYPE_CHECKING:
@@ -67,6 +68,7 @@ class RegisterBonusResult:
@dataclass(frozen=True)
class PackageInfoResult:
product_code: str
app_store_product_id: str
type: PackageType
price: float
credits: int
@@ -461,6 +463,8 @@ class PointsService:
email_hash=email_hash
)
product_mappings = _load_product_mappings()
packages: list[PackageInfoResult] = []
for pkg in pkg_config.packages:
if not pkg.enabled:
@@ -468,9 +472,13 @@ class PointsService:
if pkg.type == PackageType.STARTER and has_starter:
continue
mapping = product_mappings.get(pkg.product_code)
app_store_product_id = mapping.app_store_product_id if mapping else ""
packages.append(
PackageInfoResult(
product_code=pkg.product_code,
app_store_product_id=app_store_product_id,
type=pkg.type,
price=pkg.price,
credits=pkg.credits,
+2
View File
@@ -7,6 +7,7 @@ from v1.auth.router import router as auth_router
from v1.feedback.router import router as feedback_router
from v1.invite.router import router as invite_router
from v1.notifications.router import router as notifications_router
from v1.payments.router import router as payments_router
from v1.points.router import router as points_router
from v1.users.router import router as users_router
@@ -17,5 +18,6 @@ router.include_router(agent_router)
router.include_router(feedback_router)
router.include_router(invite_router)
router.include_router(notifications_router)
router.include_router(payments_router)
router.include_router(points_router)
router.include_router(users_router)