✨ 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:
1 parent
dfce60f7db
commit
34c481ab9e
8 files changed
+823
-2
No files matched your search
@@ -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"),
|
||||
)
|
||||
Reference in new issue
Block a user