✨ 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
@@ -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 ###
|
||||
@@ -6,7 +6,7 @@ from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.service import SESSION_COOKIE, resolve_session
|
||||
from app.db.session import get_db
|
||||
from app.db.session import DbSession, get_db
|
||||
from app.models import User
|
||||
|
||||
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
|
||||
@@ -33,10 +33,11 @@ async def get_current_user(
|
||||
|
||||
async def require_state_change(
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
|
||||
) -> User:
|
||||
"""Cookie 状态变更:Origin 校验 + CSRF 双提交(契约 2.1 节)。"""
|
||||
"""Cookie 状态变更:Origin 校验 + CSRF 双提交 + 保全态拦截(契约 2.1 / ADR-021)。"""
|
||||
if ALLOWED_ORIGINS:
|
||||
origin = request.headers.get("Origin")
|
||||
if origin is not None and origin not in ALLOWED_ORIGINS:
|
||||
@@ -44,6 +45,21 @@ async def require_state_change(
|
||||
csrf_hash = getattr(request.state, "session_csrf_hash", None)
|
||||
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败")
|
||||
# 保全态:欠费只读保全,状态变更拒绝(数据不删、恢复即解锁)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import PersonalDomain
|
||||
|
||||
domain = (
|
||||
await db.execute(
|
||||
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if domain is not None and domain.billing_status == "preserved":
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"账户处于保全态(只读):恢复付费后自动解锁,数据不会删除",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from app.core.config import get_settings
|
||||
from app.core.errors import install_error_handlers
|
||||
from app.core.middleware import RequestContextMiddleware, configure_logging
|
||||
from app.db.session import init_engine
|
||||
from app.marketplace.routes import finance_router, webhook_router
|
||||
from app.marketplace.routes import router as marketplace_router
|
||||
from app.memories.routes import cycle_router
|
||||
from app.memories.routes import router as memories_router
|
||||
from app.tools.routes import calls_router, tools_router
|
||||
@@ -37,6 +39,9 @@ def create_app() -> FastAPI:
|
||||
app.include_router(memories_router)
|
||||
app.include_router(tools_router)
|
||||
app.include_router(calls_router)
|
||||
app.include_router(marketplace_router)
|
||||
app.include_router(finance_router)
|
||||
app.include_router(webhook_router)
|
||||
|
||||
@app.get("/healthz", tags=["ops"])
|
||||
async def healthz() -> dict[str, str]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""交易域:商品、订单、账本、退款与 Webhook。"""
|
||||
@@ -0,0 +1,169 @@
|
||||
"""交易域路由(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}
|
||||
@@ -0,0 +1,272 @@
|
||||
"""交易域:商品、订单、不可变账本、退款与 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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -1016,3 +1016,131 @@ async def _wait_run_terminal_headers(client, headers, limit_s: float = 10.0) ->
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
class TestMarketplace:
|
||||
async def _make_listing(self, client, headers, price=1000) -> str:
|
||||
resp = await client.post(
|
||||
"/v1/marketplace/listings",
|
||||
json={"title": "角色包·昭星", "price_minor": price}, headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
async def _make_order(self, client, headers, listing_id, key) -> dict:
|
||||
resp = await client.post(
|
||||
"/v1/marketplace/orders",
|
||||
json={"listing_ids": [listing_id]},
|
||||
headers={**headers, "Idempotency-Key": key},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
return resp.json()
|
||||
|
||||
async def test_purchase_ledger_double_entry(self, client, db) -> None:
|
||||
headers = await login(client, "mkt1@example.com")
|
||||
lid = await self._make_listing(client, headers, price=1000)
|
||||
order = await self._make_order(client, headers, lid, "ord-1")
|
||||
assert order["status"] == "pending_payment" and order["total_minor"] == 1000
|
||||
paid = await client.post(
|
||||
f"/v1/marketplace/orders/{order['id']}/payment-confirmations", headers=headers
|
||||
)
|
||||
assert paid.json()["status"] == "paid"
|
||||
summary = await client.get("/v1/finance/summary", headers=headers)
|
||||
totals = summary.json()["totals_minor"]
|
||||
assert totals["charge"] == 1000
|
||||
assert totals["platform_fee"] == 100 # 10%
|
||||
assert totals["creator_payable"] == 900
|
||||
assert summary.json()["balanced"] is True
|
||||
|
||||
async def test_order_idempotent_replay(self, client) -> None:
|
||||
headers = await login(client, "mkt2@example.com")
|
||||
lid = await self._make_listing(client, headers)
|
||||
first = await self._make_order(client, headers, lid, "ord-replay")
|
||||
second = await client.post(
|
||||
"/v1/marketplace/orders", json={"listing_ids": [lid]},
|
||||
headers={**headers, "Idempotency-Key": "ord-replay"},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["id"] == first["id"]
|
||||
|
||||
async def test_refund_writes_reversal_entries(self, client) -> None:
|
||||
headers = await login(client, "mkt3@example.com")
|
||||
lid = await self._make_listing(client, headers, price=2000)
|
||||
order = await self._make_order(client, headers, lid, "ord-refund")
|
||||
await client.post(
|
||||
f"/v1/marketplace/orders/{order['id']}/payment-confirmations", headers=headers
|
||||
)
|
||||
refunded = await client.post(
|
||||
f"/v1/marketplace/orders/{order['id']}/refunds", headers=headers
|
||||
)
|
||||
assert refunded.json()["status"] == "refunded"
|
||||
# 重复退款幂等
|
||||
again = await client.post(
|
||||
f"/v1/marketplace/orders/{order['id']}/refunds", headers=headers
|
||||
)
|
||||
assert again.json()["status"] == "refunded"
|
||||
summary = await client.get("/v1/finance/summary", headers=headers)
|
||||
totals = summary.json()["totals_minor"]
|
||||
# charge=2000, reversal=-2000 → 净 0;fee/payable 各自正负抵消
|
||||
assert totals.get("charge", 0) + totals.get("reversal", 0) == 0
|
||||
assert summary.json()["balanced"] is True
|
||||
|
||||
async def test_webhook_signature_and_idempotency(self, client) -> None:
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import os
|
||||
|
||||
headers = await login(client, "mkt4@example.com")
|
||||
lid = await self._make_listing(client, headers, price=500)
|
||||
order = await self._make_order(client, headers, lid, "ord-wh")
|
||||
payload = json.dumps({
|
||||
"provider_event_id": "evt-001", "type": "payment.succeeded",
|
||||
"order_id": order["id"],
|
||||
})
|
||||
secret = os.environ.get("HUNJING_WEBHOOK_SECRET", "whsec_test")
|
||||
os.environ["HUNJING_WEBHOOK_SECRET"] = "whsec_test"
|
||||
sig = hmac_mod.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
# 错误签名:401
|
||||
bad = await client.post(
|
||||
"/v1/webhooks/v1/payments/mock", content=payload,
|
||||
headers={"X-Signature": "deadbeef", "Content-Type": "application/json"},
|
||||
)
|
||||
assert bad.status_code == 401
|
||||
# 正确签名:200 处理
|
||||
ok = await client.post(
|
||||
"/v1/webhooks/v1/payments/mock", content=payload,
|
||||
headers={"X-Signature": sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert ok.status_code == 200 and ok.json()["result"] == "processed"
|
||||
# 重复投递:幂等 duplicate
|
||||
dup = await client.post(
|
||||
"/v1/webhooks/v1/payments/mock", content=payload,
|
||||
headers={"X-Signature": sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert dup.json()["result"] == "duplicate"
|
||||
|
||||
async def test_preservation_readonly_and_recovery(self, client) -> None:
|
||||
headers = await login(client, "mkt5@example.com")
|
||||
await client.post(
|
||||
"/v1/finance/preservation", json={"preserved": True}, headers=headers
|
||||
)
|
||||
# 状态变更被拒绝(保全只读)
|
||||
blocked = await client.post(
|
||||
"/v1/conversations", json={"title": "保全期"}, headers=headers
|
||||
)
|
||||
assert blocked.status_code == 403
|
||||
assert "保全" in blocked.json()["detail"]
|
||||
# 读取不受影响:数据仍在
|
||||
listing_resp = await client.post( # 验证写拦截一致
|
||||
"/v1/marketplace/listings", json={"title": "x"}, headers=headers
|
||||
)
|
||||
assert listing_resp.status_code == 403
|
||||
# 恢复:写恢复
|
||||
await client.post(
|
||||
"/v1/finance/preservation", json={"preserved": False}, headers=headers
|
||||
)
|
||||
recovered = await client.post(
|
||||
"/v1/conversations", json={"title": "恢复"}, headers=headers
|
||||
)
|
||||
assert recovered.status_code == 201
|
||||
Reference in new issue
Block a user