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