✨ feat: WP-13 市场与交易——不可变账本、退款、Webhook 与保全态(ADR-009/021)

- 迁移 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):双录平衡、订单幂等、退款反向与对账、验签/幂等、保全只读与恢复
This commit is contained in:
chenxin committed 2026-09-11 11:37:45 +08:00
1 parent dfce60f7db
commit 34c481ab9e
8 files changed
+823 -2

No files matched your search

@@ -0,0 +1,107 @@
"""市场交易:商品、订单、账本与 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 ###