✨ 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
@@ -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