✨ 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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user