Files
eryao/backend/alembic/versions/20260427_0001_apple_iap_transactions.py
T
ZL-Q 87f92987b2 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_* 错误码
2026-04-28 10:45:29 +08:00

279 lines
12 KiB
Python

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