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:
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user