- 迁移 11:listing、order(六态 + 幂等唯一约束)、order_item(价格快照,客户端金额不可信)、ledger_entry(不可变 8 类型带符号分录)、webhook_event(Provider 事件 ID 幂等)、personal_domain.billing_status(保全态) - 购买闭环:下单冻结价格快照 → 支付确认同事务写三分录(charge = platform_fee 10% + creator_payable,双录平衡校验拒绝不平衡提交) - 退款:反向三条分录修正账本(Ledger 不删不改,ADR-009);重复退款幂等;汇总端点对账 balanced=true - Webhook:HMAC-SHA256 验签(密钥经环境变量,错签 401)→ 按 Provider 事件 ID 幂等(重复投递 duplicate 不重复入账)→ 买方从订单反查(不信任 payload) - 保全态:欠费只读保全(全部状态变更 403 且明示不删数据;解除端点不被保全自锁);读取不受影响,恢复即解锁——ADR-021 落地 - 测试 72 项全绿(+5):双录平衡、订单幂等、退款反向与对账、验签/幂等、保全只读与恢复
108 lines
5.9 KiB
Python
108 lines
5.9 KiB
Python
"""市场交易:商品、订单、账本与 Webhook
|
|
|
|
Revision ID: b7f1a0032451
|
|
Revises: b3a26cb61923
|
|
Create Date: 2026-09-11 11:31:22.345265
|
|
|
|
"""
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from alembic import op
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = 'b7f1a0032451'
|
|
down_revision: str | Sequence[str] | None = 'b3a26cb61923'
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
"""Upgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.create_table('webhook_event',
|
|
sa.Column('provider_event_id', sa.String(), nullable=False),
|
|
sa.Column('provider', sa.String(), nullable=False),
|
|
sa.Column('event_type', sa.String(), nullable=False),
|
|
sa.Column('payload_hash', sa.String(), nullable=False),
|
|
sa.Column('status', sa.String(), nullable=False),
|
|
sa.Column('received_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.CheckConstraint("status IN ('processed', 'ignored')", name=op.f('ck_webhook_event_status_values')),
|
|
sa.PrimaryKeyConstraint('provider_event_id', name=op.f('pk_webhook_event'))
|
|
)
|
|
op.create_table('listing',
|
|
sa.Column('id', sa.String(), nullable=False),
|
|
sa.Column('creator_domain_id', sa.String(), nullable=False),
|
|
sa.Column('title', sa.String(), nullable=False),
|
|
sa.Column('price_minor', sa.Integer(), nullable=False),
|
|
sa.Column('currency', sa.String(), nullable=False),
|
|
sa.Column('status', sa.String(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.CheckConstraint('price_minor >= 0', name=op.f('ck_listing_price_non_negative')),
|
|
sa.ForeignKeyConstraint(['creator_domain_id'], ['personal_domain.id'], name=op.f('fk_listing_personal_domain'), ondelete='cascade'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_listing'))
|
|
)
|
|
op.create_index(op.f('ix_listing_creator_domain_id'), 'listing', ['creator_domain_id'], unique=False)
|
|
op.create_table('order',
|
|
sa.Column('id', sa.String(), nullable=False),
|
|
sa.Column('buyer_domain_id', sa.String(), nullable=False),
|
|
sa.Column('status', sa.String(), nullable=False),
|
|
sa.Column('total_minor', sa.Integer(), nullable=False),
|
|
sa.Column('currency', sa.String(), nullable=False),
|
|
sa.Column('idempotency_key', sa.String(), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True),
|
|
sa.CheckConstraint("status IN ('pending_payment', 'paid', 'partially_refunded', 'refunded', 'disputed', 'cancelled')", name=op.f('ck_order_status_values')),
|
|
sa.ForeignKeyConstraint(['buyer_domain_id'], ['personal_domain.id'], name=op.f('fk_order_personal_domain'), ondelete='cascade'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_order'))
|
|
)
|
|
op.create_index(op.f('ix_order_buyer_domain_id'), 'order', ['buyer_domain_id'], unique=False)
|
|
op.create_index('uq_order_buyer_idempotency', 'order', ['buyer_domain_id', 'idempotency_key'], unique=True)
|
|
op.create_table('ledger_entry',
|
|
sa.Column('id', sa.String(), nullable=False),
|
|
sa.Column('order_id', sa.String(), nullable=False),
|
|
sa.Column('entry_type', sa.String(), nullable=False),
|
|
sa.Column('amount_minor', sa.Integer(), nullable=False),
|
|
sa.Column('currency', sa.String(), nullable=False),
|
|
sa.Column('note', sa.String(), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
sa.CheckConstraint("entry_type IN ('charge', 'refund', 'platform_fee', 'creator_payable', 'tax', 'reserve', 'payout', 'reversal')", name=op.f('ck_ledger_entry_entry_type_values')),
|
|
sa.ForeignKeyConstraint(['order_id'], ['order.id'], name=op.f('fk_ledger_entry_order'), ondelete='restrict'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_ledger_entry'))
|
|
)
|
|
op.create_index(op.f('ix_ledger_entry_order_id'), 'ledger_entry', ['order_id'], unique=False)
|
|
op.create_index('ix_ledger_order_type', 'ledger_entry', ['order_id', 'entry_type'], unique=False)
|
|
op.create_table('order_item',
|
|
sa.Column('id', sa.String(), nullable=False),
|
|
sa.Column('order_id', sa.String(), nullable=False),
|
|
sa.Column('listing_id', sa.String(), nullable=False),
|
|
sa.Column('title_snapshot', sa.String(), nullable=False),
|
|
sa.Column('price_minor_snapshot', sa.Integer(), nullable=False),
|
|
sa.ForeignKeyConstraint(['listing_id'], ['listing.id'], name=op.f('fk_order_item_listing'), ondelete='restrict'),
|
|
sa.ForeignKeyConstraint(['order_id'], ['order.id'], name=op.f('fk_order_item_order'), ondelete='cascade'),
|
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_order_item'))
|
|
)
|
|
op.create_index(op.f('ix_order_item_order_id'), 'order_item', ['order_id'], unique=False)
|
|
op.add_column('personal_domain', sa.Column('billing_status', sa.String(), nullable=False, server_default='ok'))
|
|
# ### end Alembic commands ###
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""Downgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.drop_column('personal_domain', 'billing_status')
|
|
op.drop_index(op.f('ix_order_item_order_id'), table_name='order_item')
|
|
op.drop_table('order_item')
|
|
op.drop_index('ix_ledger_order_type', table_name='ledger_entry')
|
|
op.drop_index(op.f('ix_ledger_entry_order_id'), table_name='ledger_entry')
|
|
op.drop_table('ledger_entry')
|
|
op.drop_index('uq_order_buyer_idempotency', table_name='order')
|
|
op.drop_index(op.f('ix_order_buyer_domain_id'), table_name='order')
|
|
op.drop_table('order')
|
|
op.drop_index(op.f('ix_listing_creator_domain_id'), table_name='listing')
|
|
op.drop_table('listing')
|
|
op.drop_table('webhook_event')
|
|
# ### end Alembic commands ###
|