Files
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

170 lines
5.9 KiB
Python

"""交易域路由(WP-13)。"""
import hashlib
import json
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.marketplace import service
from app.models import Listing, Order, User
router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
finance_router = APIRouter(prefix="/v1/finance", tags=["finance"])
webhook_router = APIRouter(prefix="/v1/webhooks/v1", tags=["webhooks"])
def _listing_out(item: Listing) -> dict[str, Any]:
return {
"id": item.id, "title": item.title, "price_minor": item.price_minor,
"currency": item.currency, "status": item.status,
}
def _order_out(o: Order) -> dict[str, Any]:
return {
"id": o.id, "status": o.status, "total_minor": o.total_minor,
"currency": o.currency,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"refunded_at": o.refunded_at.isoformat() if o.refunded_at else None,
}
@router.post("/listings", status_code=201)
async def create_listing(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
listing = await service.create_listing(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _listing_out(listing)
@router.get("/listings")
async def list_listings(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
rows = list((await db.scalars(select(Listing).limit(50))).all())
return {"data": [_listing_out(item) for item in rows]}
@router.post("/orders", status_code=201, response_model=None)
async def create_order(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> dict[str, Any] | JSONResponse:
if idempotency_key:
body = {**body, "idempotency_key": idempotency_key}
domain = await _domain_of(db, user)
order, replayed = await service.create_order(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
if replayed:
return JSONResponse(status_code=200, content=_order_out(order))
return _order_out(order)
@router.post("/orders/{order_id}/payment-confirmations", status_code=201)
async def confirm_payment(
order_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
order = await service.confirm_payment(
db, domain.id, order_id, getattr(request.state, "trace_id", None)
)
return _order_out(order)
@router.post("/orders/{order_id}/refunds", status_code=201)
async def refund(
order_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
order = await service.refund_order(
db, domain.id, order_id, getattr(request.state, "trace_id", None)
)
return _order_out(order)
@finance_router.get("/summary")
async def finance_summary(
db: DbSession, user: Annotated[User, Depends(get_current_user)]
) -> dict[str, Any]:
domain = await _domain_of(db, user)
return await service.finance_summary(db, domain.id)
@finance_router.post("/preservation", response_model=None)
async def set_preservation(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
request: Request,
body: dict[str, bool],
) -> dict[str, str]:
"""保全态切换(欠费只读;开发档由计费系统触发,验证用)。
不经 require_state_change:保全期间必须能解除保全(否则自锁)。
"""
domain = await _domain_of(db, user)
value = await service.set_preservation(
db, domain.id, bool(body.get("preserved")), getattr(request.state, "trace_id", None)
)
return {"billing_status": value}
@webhook_router.post("/payments/mock", status_code=200)
async def payment_webhook(
request: Request,
db: DbSession,
x_signature: Annotated[str | None, Header(alias="X-Signature")] = None,
) -> dict[str, str]:
"""支付 Provider Webhook:先验签(原始字节),再按事件 ID 幂等处理。"""
raw = await request.body()
service.verify_webhook_signature(raw, x_signature)
try:
payload: dict[str, Any] = json.loads(raw)
except json.JSONDecodeError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "payload 不是 JSON") from exc
event_type = str(payload.get("type", "payment.succeeded"))
provider_event_id = str(payload.get("provider_event_id", ""))
order_id = str(payload.get("order_id", ""))
if not provider_event_id or not order_id:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "缺少事件标识")
order = await db.get(Order, order_id)
if order is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "订单不存在")
buyer_domain_id = order.buyer_domain_id
async def handler() -> None:
if event_type == "payment.succeeded":
await service.confirm_payment(db, buyer_domain_id, order_id, None)
elif event_type == "payment.refunded":
await service.refund_order(db, buyer_domain_id, order_id, None)
else:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "未知事件类型")
payload_hash = hashlib.sha256(raw).hexdigest()
result = await service.process_webhook(
db, "mock", provider_event_id, event_type, payload_hash,
handler, getattr(request.state, "trace_id", None),
)
return {"result": result}