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:
@@ -0,0 +1,278 @@
|
||||
"""add apple_iap_transactions table and update points_ledger constraints
|
||||
|
||||
Revision ID: 20260427_0001
|
||||
Revises: 20260417_0001
|
||||
Create Date: 2026-04-27 12:00:00
|
||||
|
||||
Changes:
|
||||
1. Create apple_iap_transactions table for Apple IAP payment tracking
|
||||
2. Update points_ledger check constraints:
|
||||
- Remove 'grant' from change_type (merged into 'adjust')
|
||||
- Add 'purchase' and 'refund' to change_type
|
||||
- Add 'payment' to biz_type
|
||||
- Update biz_binding constraint for new types
|
||||
- Update direction_by_change_type constraint
|
||||
- Add metadata shape constraints for purchase/refund
|
||||
- Update adjust metadata constraint (ticket_id -> reason)
|
||||
3. Update points_audit_ledger check constraints similarly
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260427_0001"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260417_0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"apple_iap_transactions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("product_code", sa.String(32), nullable=False),
|
||||
sa.Column("app_store_product_id", sa.String(128), nullable=False),
|
||||
sa.Column("transaction_id", sa.String(64), nullable=False),
|
||||
sa.Column("original_transaction_id", sa.String(64), nullable=True),
|
||||
sa.Column("web_order_line_item_id", sa.String(64), nullable=True),
|
||||
sa.Column("environment", sa.String(16), nullable=False),
|
||||
sa.Column("bundle_id", sa.String(128), nullable=False),
|
||||
sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("purchase_date", sa.Text, nullable=False),
|
||||
sa.Column("revocation_date", sa.Text, nullable=True),
|
||||
sa.Column("status", sa.String(24), nullable=False),
|
||||
sa.Column("credits", sa.BigInteger, nullable=False),
|
||||
sa.Column("currency", sa.String(8), nullable=True),
|
||||
sa.Column("price_milliunits", sa.BigInteger, nullable=True),
|
||||
sa.Column("ledger_event_id", sa.String(64), nullable=True),
|
||||
sa.Column("signed_transaction_info", sa.Text, nullable=False),
|
||||
sa.Column(
|
||||
"apple_payload",
|
||||
postgresql.JSONB(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column("failure_code", sa.String(64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"environment in ('Sandbox', 'Production')",
|
||||
name="ck_apple_iap_transactions_environment",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
|
||||
name="ck_apple_iap_transactions_status",
|
||||
),
|
||||
sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"),
|
||||
sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_apple_iap_transactions_user_created_at",
|
||||
"apple_iap_transactions",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_apple_iap_transactions_status_updated_at",
|
||||
"apple_iap_transactions",
|
||||
["status", sa.text("updated_at DESC")],
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE apple_iap_transactions ENABLE ROW LEVEL SECURITY")
|
||||
|
||||
op.execute(
|
||||
"CREATE POLICY anon_select_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR SELECT TO anon USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO anon WITH CHECK (true)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_update_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR UPDATE TO anon USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_delete_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR DELETE TO anon USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_select_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR SELECT TO authenticated USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO authenticated WITH CHECK (true)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_update_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR UPDATE TO authenticated USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_delete_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR DELETE TO authenticated USING (false)"
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_change_type",
|
||||
"points_ledger",
|
||||
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_type",
|
||||
"points_ledger",
|
||||
"biz_type is null or biz_type in ('chat', 'payment')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_binding",
|
||||
"points_ledger",
|
||||
"((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))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_direction_by_change_type",
|
||||
"points_ledger",
|
||||
"((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)))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_adjust_shape",
|
||||
"points_ledger",
|
||||
"(change_type <> 'adjust' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'reason') and "
|
||||
"coalesce(metadata #>> '{ext,reason}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_payment_shape",
|
||||
"points_ledger",
|
||||
"(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}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_refund_shape",
|
||||
"points_ledger",
|
||||
"(change_type <> 'refund' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and "
|
||||
"coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_change_type",
|
||||
"points_audit_ledger",
|
||||
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_biz_type",
|
||||
"points_audit_ledger",
|
||||
"biz_type is null or biz_type in ('chat', 'payment')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_biz_type",
|
||||
"points_audit_ledger",
|
||||
"biz_type is null or biz_type = 'chat'",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_change_type",
|
||||
"points_audit_ledger",
|
||||
"change_type in ('register', 'consume', 'grant', 'adjust')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_metadata_refund_shape", "points_ledger", type_="check")
|
||||
op.drop_constraint("ck_points_ledger_metadata_payment_shape", "points_ledger", type_="check")
|
||||
|
||||
op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_adjust_shape",
|
||||
"points_ledger",
|
||||
"(change_type <> 'adjust' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and "
|
||||
"coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_direction_by_change_type",
|
||||
"points_ledger",
|
||||
"((change_type in ('register', 'grant') and direction = 1) or "
|
||||
"(change_type = 'consume' and direction = -1) or "
|
||||
"(change_type = 'adjust' and direction in (1, -1)))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_binding",
|
||||
"points_ledger",
|
||||
"((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))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_type",
|
||||
"points_ledger",
|
||||
"biz_type is null or biz_type = 'chat'",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_change_type",
|
||||
"points_ledger",
|
||||
"change_type in ('register', 'consume', 'grant', 'adjust')",
|
||||
)
|
||||
|
||||
op.drop_index("ix_apple_iap_transactions_status_updated_at", table_name="apple_iap_transactions")
|
||||
op.drop_index("ix_apple_iap_transactions_user_created_at", table_name="apple_iap_transactions")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_delete_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_update_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_select_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_delete_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_update_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_select_apple_iap_transactions ON apple_iap_transactions")
|
||||
|
||||
op.execute("ALTER TABLE apple_iap_transactions DISABLE ROW LEVEL SECURITY")
|
||||
|
||||
op.drop_table("apple_iap_transactions")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""fix apple_iap_transactions RLS policies for INSERT
|
||||
|
||||
Revision ID: 20260427_0002
|
||||
Revises: 20260427_0001
|
||||
Create Date: 2026-04-27 18:00:00
|
||||
|
||||
Changes:
|
||||
1. Fix anon_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false)
|
||||
2. Fix authenticated_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false)
|
||||
|
||||
Rationale:
|
||||
Apple IAP transactions should only be created by backend service_role,
|
||||
not by client anon/authenticated users. The original policies allowed
|
||||
unrestricted INSERT which bypasses RLS security.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260427_0002"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260427_0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO anon WITH CHECK (false)"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO authenticated WITH CHECK (false)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO authenticated WITH CHECK (true)"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO anon WITH CHECK (true)"
|
||||
)
|
||||
Reference in New Issue
Block a user