Files
hunjing/backend/app/marketplace/service.py
T
chenxin 34c481ab9e ✨ 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):双录平衡、订单幂等、退款反向与对账、验签/幂等、保全只读与恢复
2026-09-11 11:37:45 +08:00

273 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""交易域:商品、订单、不可变账本、退款与 Webhook(WP-13 / ADR-009/021)。"""
import hashlib
import hmac
import os
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
AuditEvent,
LedgerEntry,
Listing,
Order,
OrderItem,
OutboxEvent,
WebhookEvent,
)
PLATFORM_FEE_BPS = 1000 # 平台费 10%(基点;正式费率由运营决策门登记)
def _ledger_balance_check(entries: list[tuple[str, int]]) -> None:
"""平衡不变量:sum(charge+reversal) == sum(fee+payable 及其 reversal)。"""
left = sum(a for t, a in entries if t in {"charge", "reversal"})
right = sum(
a for t, a in entries
if t in {"platform_fee", "creator_payable", "refund"}
)
if left != right:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "账本不平衡:拒绝提交"
)
async def create_listing(
db: AsyncSession, domain_id: str, body: dict[str, Any], trace_id: str | None
) -> Listing:
listing = Listing(
creator_domain_id=domain_id,
title=str(body.get("title", "")),
price_minor=int(body.get("price_minor", 0)),
currency=str(body.get("currency", "CNY")),
)
if not listing.title.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "title 不能为空")
db.add(listing)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="listing.created",
aggregate_type="listing", aggregate_id=listing.id, trace_id=trace_id,
)
)
await db.flush()
return listing
async def create_order(
db: AsyncSession, buyer_domain_id: str, body: dict[str, Any], trace_id: str | None
) -> tuple[Order, bool]:
"""下单:价格快照冻结(客户端金额不可信);幂等键重放返回原订单。"""
key = body.get("idempotency_key")
if key:
existing = await db.execute(
select(Order).where(
Order.buyer_domain_id == buyer_domain_id,
Order.idempotency_key == key,
)
)
replay = existing.scalar_one_or_none()
if replay is not None:
return replay, True
listing_ids = body.get("listing_ids", [])
if not listing_ids:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "listing_ids 不能为空")
listings = list(
(
await db.execute(select(Listing).where(Listing.id.in_(listing_ids)))
).scalars().all()
)
if len(listings) != len(set(listing_ids)):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "存在无效商品")
total = sum(x.price_minor for x in listings)
currency = listings[0].currency
if any(x.currency != currency for x in listings):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "订单不能混合币种")
order = Order(
buyer_domain_id=buyer_domain_id,
total_minor=total,
currency=currency,
idempotency_key=key,
)
db.add(order)
await db.flush()
for listing in listings:
db.add(
OrderItem(
order_id=order.id,
listing_id=listing.id,
title_snapshot=listing.title,
price_minor_snapshot=listing.price_minor,
)
)
db.add(
AuditEvent(
actor_type="user", actor_id=buyer_domain_id, action="order.created",
aggregate_type="order", aggregate_id=order.id,
payload={"total_minor": total}, trace_id=trace_id,
)
)
await db.flush()
return order, False
async def _post_entries(
db: AsyncSession, order: Order, sign: int, note: str
) -> None:
"""写三分录(带符号):charge / platform_fee / creator_payable。
charge=fee+payable(双录平衡);退款以 sign=-1 写反向三条。
"""
fee = order.total_minor * PLATFORM_FEE_BPS // 10000
payable = order.total_minor - fee
entries = [
("charge" if sign > 0 else "reversal", order.total_minor * sign),
("platform_fee", fee * sign),
("creator_payable", payable * sign),
]
checked = [(t, a) for t, a in entries if a != 0]
_ledger_balance_check(checked)
for entry_type, amount in checked:
db.add(
LedgerEntry(
order_id=order.id, entry_type=entry_type,
amount_minor=amount, currency=order.currency,
note=note,
)
)
async def confirm_payment(
db: AsyncSession, buyer_domain_id: str, order_id: str, trace_id: str | None
) -> Order:
"""支付确认(开发档 Provider;真实 Provider 经 Webhook 驱动同一入账)。"""
order = await db.get(Order, order_id)
if order is None or order.buyer_domain_id != buyer_domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
if order.status == "paid":
return order # 幂等
if order.status != "pending_payment":
raise HTTPException(status.HTTP_409_CONFLICT, f"订单状态 {order.status} 不可支付")
order.status = "paid"
order.paid_at = datetime.now(UTC)
await _post_entries(db, order, +1, "payment confirmed")
db.add(
OutboxEvent(
aggregate_type="order", aggregate_id=order.id,
event_type="order.paid", payload={"order_id": order.id},
)
)
db.add(
AuditEvent(
actor_type="user", actor_id=buyer_domain_id, action="order.paid",
aggregate_type="order", aggregate_id=order.id, trace_id=trace_id,
)
)
await db.flush()
return order
async def refund_order(
db: AsyncSession, buyer_domain_id: str, order_id: str, trace_id: str | None
) -> Order:
"""全额退款:反向分录修正账本(Ledger 不可变,不删不改,ADR-009)。"""
order = await db.get(Order, order_id)
if order is None or order.buyer_domain_id != buyer_domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
if order.status == "refunded":
return order # 幂等
if order.status != "paid":
raise HTTPException(status.HTTP_409_CONFLICT, "仅已支付订单可退款")
order.status = "refunded"
order.refunded_at = datetime.now(UTC)
await _post_entries(db, order, -1, "refund")
db.add(
OutboxEvent(
aggregate_type="order", aggregate_id=order.id,
event_type="order.refunded", payload={"order_id": order.id},
)
)
db.add(
AuditEvent(
actor_type="user", actor_id=buyer_domain_id, action="order.refunded",
aggregate_type="order", aggregate_id=order.id, trace_id=trace_id,
)
)
await db.flush()
return order
def verify_webhook_signature(raw_body: bytes, signature: str | None) -> None:
"""HMAC-SHA256 验签:密钥经环境变量;失败 401(契约 realtime-and-webhooks)。"""
secret = os.environ.get("HUNJING_WEBHOOK_SECRET", "")
if not secret:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "Webhook 密钥未配置")
if signature is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "缺少签名")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "签名校验失败")
async def process_webhook(
db: AsyncSession, provider: str, provider_event_id: str, event_type: str,
payload_hash: str, handler: Any, trace_id: str | None,
) -> str:
"""Webhook 处理:先验签(调用方)、按事件 ID 幂等;重复事件 200 跳过。"""
existing = await db.get(WebhookEvent, provider_event_id)
if existing is not None:
return "duplicate" # 幂等:重复投递不重复入账
db.add(
WebhookEvent(
provider_event_id=provider_event_id,
provider=provider,
event_type=event_type,
payload_hash=payload_hash,
)
)
await handler() # 业务处理(如入账)与本记录同一事务
await db.flush()
return "processed"
async def finance_summary(db: AsyncSession, domain_id: str) -> dict[str, Any]:
"""域内财务汇总与平衡校验(每订单 charge=reversal 与 fee+payable 对账)。"""
rows = (
await db.execute(
select(LedgerEntry.entry_type, func.sum(LedgerEntry.amount_minor))
.join(Order, LedgerEntry.order_id == Order.id)
.where(Order.buyer_domain_id == domain_id)
.group_by(LedgerEntry.entry_type)
)
).all()
totals = {t: int(a or 0) for t, a in rows}
balance_ok = totals.get("charge", 0) + totals.get("reversal", 0) == (
totals.get("platform_fee", 0) + totals.get("creator_payable", 0)
)
return {"totals_minor": totals, "balanced": balance_ok}
async def set_preservation(
db: AsyncSession, domain_id: str, preserved: bool, trace_id: str | None
) -> str:
"""保全态(ADR-021):欠费只读保全;恢复付费即恢复,数据不删不降级。"""
from app.models import PersonalDomain
domain = await db.get(PersonalDomain, domain_id)
assert domain is not None
domain.billing_status = "preserved" if preserved else "ok"
db.add(
AuditEvent(
actor_type="system", actor_id="billing", action="domain.preservation_changed",
aggregate_type="personal_domain", aggregate_id=domain_id,
payload={"preserved": preserved}, trace_id=trace_id,
)
)
await db.flush()
return domain.billing_status