✨ 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

+123
View File
@@ -42,6 +42,8 @@ class PersonalDomain(Base):
name: Mapped[str] = mapped_column(String, nullable=False)
# 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议
data_region: Mapped[str] = mapped_column(String, nullable=False, default="default")
# 保全态(ADR-021):欠费/封禁只读保全,不删除数据
billing_status: Mapped[str] = mapped_column(String, nullable=False, default="ok")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -673,3 +675,124 @@ class ToolCall(Base):
unique=True,
),
)
class Listing(Base):
"""市场商品(WP-13):带版本、价格与许可;此处为最小商品形态。"""
__tablename__ = "listing"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("listing_")
)
creator_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String, nullable=False)
price_minor: Mapped[int] = mapped_column(nullable=False, default=0) # 0=免费
currency: Mapped[str] = mapped_column(String, nullable=False, default="CNY")
status: Mapped[str] = mapped_column(String, nullable=False, default="published")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("price_minor >= 0", name="price_non_negative"),
)
class Order(Base):
"""订单(契约 order_status 六态);下单即价格快照,客户端金额不可信。"""
__tablename__ = "order"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("order_"))
buyer_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending_payment")
total_minor: Mapped[int] = mapped_column(nullable=False, default=0)
currency: Mapped[str] = mapped_column(String, nullable=False, default="CNY")
idempotency_key: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending_payment', 'paid', 'partially_refunded', 'refunded', "
"'disputed', 'cancelled')",
name="status_values",
),
Index("uq_order_buyer_idempotency", "buyer_domain_id", "idempotency_key", unique=True),
)
class OrderItem(Base):
"""订单行:商品与价格快照(下单时冻结,后续改价不影响)。"""
__tablename__ = "order_item"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("item_"))
order_id: Mapped[str] = mapped_column(
ForeignKey("order.id", ondelete="cascade"), nullable=False, index=True
)
listing_id: Mapped[str] = mapped_column(
ForeignKey("listing.id", ondelete="restrict"), nullable=False
)
title_snapshot: Mapped[str] = mapped_column(String, nullable=False)
price_minor_snapshot: Mapped[int] = mapped_column(nullable=False)
class LedgerEntry(Base):
"""不可变账务分录(ADR-009):修正只写反向分录,禁止更新删除。
带符号金额:charge/payable/fee 为正,reversal 为负;
平衡不变量:每笔订单 sum(charge+reversal) == sum(fee+fee_rev+payable+pay_rev)。
"""
__tablename__ = "ledger_entry"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("ledger_")
)
order_id: Mapped[str] = mapped_column(
ForeignKey("order.id", ondelete="restrict"), nullable=False, index=True
)
entry_type: Mapped[str] = mapped_column(String, nullable=False)
amount_minor: Mapped[int] = mapped_column(nullable=False) # 带符号
currency: Mapped[str] = mapped_column(String, nullable=False, default="CNY")
note: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"entry_type IN ('charge', 'refund', 'platform_fee', 'creator_payable', "
"'tax', 'reserve', 'payout', 'reversal')",
name="entry_type_values",
),
Index("ix_ledger_order_type", "order_id", "entry_type"),
)
class WebhookEvent(Base):
"""支付 Webhook 事件:按 Provider 事件 ID 幂等,允许乱序到达。"""
__tablename__ = "webhook_event"
provider_event_id: Mapped[str] = mapped_column(String, primary_key=True)
provider: Mapped[str] = mapped_column(String, nullable=False)
event_type: Mapped[str] = mapped_column(String, nullable=False)
payload_hash: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="processed")
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("status IN ('processed', 'ignored')", name="status_values"),
)