#939 应用市场产品计费 webhook 设计 (HMAC 验签 + 幂等去重 + reconcile worker)
This commit is contained in:
1 parent
0820d408eb
commit
56eb9f1634
1 file changed
+185
@@ -0,0 +1,185 @@
|
||||
# 应用市场产品计费 Webhook 设计 — #939
|
||||
|
||||
> **任务**:#939 — 市场产品计费 webhook 设计
|
||||
> **日期**:2026-08-09
|
||||
> **状态**:✅ 设计完成 / 留待 MuMuAINovel 上线后接入
|
||||
|
||||
---
|
||||
|
||||
## 1. 业务诉求
|
||||
|
||||
平台应用市场(`market_products`)中,产品可以是:
|
||||
- **免费 SaaS**:无计费,接入即可用
|
||||
- **按量计费(metered)**:每次调用扣账户余额
|
||||
- **订阅(subscription)**:月度/年固定费
|
||||
- **免费增值(freemium)**:基础免费 / 高级付费
|
||||
|
||||
当前 4 个计费方案中,前两种(`free`)零成本。`metered` / `subscription` / `freemium` 需要:
|
||||
1. 计费事件触发(产品方在用户用了功能后回调)
|
||||
2. 对账(平台账与产品方账一致)
|
||||
3. 重试(网络抖动不能丢单)
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
### 2.1 单向事件流(平台 = 计费接收方)
|
||||
|
||||
```
|
||||
产品方后端 → POST /app/api/market/products/{id}/billing-event
|
||||
→ 平台校验签名(HMAC-SHA256)
|
||||
→ 落 im_billing_events 表
|
||||
→ 异步 reconcile worker → 扣用户余额 / 开订阅
|
||||
→ 200 OK + {accepted: true, event_id}
|
||||
```
|
||||
|
||||
**关键设计点**:
|
||||
- Webhook 是单向事件流,**不假设产品方按序回调**(用 event_id 去重)
|
||||
- 平台**幂等**:同一 event_id 重复回调不重复扣费
|
||||
- 平台**有状态**:余额扣减事务化,失败可重试
|
||||
|
||||
### 2.2 数据表 `im_billing_events`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_billing_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE, -- 产品方传的去重键(UUID)
|
||||
product_id UUID NOT NULL, -- market_products.id
|
||||
user_id UUID, -- 关联平台用户(可空:订阅场景)
|
||||
event_type TEXT NOT NULL, -- 'usage' / 'subscription_start' / 'subscription_end' / 'refund'
|
||||
quantity DECIMAL(18, 6) NOT NULL DEFAULT 1,
|
||||
unit_price DECIMAL(18, 6), -- 单价(可选,平台基于 product.billing_plan 校验)
|
||||
amount DECIMAL(18, 6) NOT NULL, -- 扣减金额(分)
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
raw_payload JSONB NOT NULL, -- 产品方完整 payload,供对账
|
||||
signature TEXT NOT NULL, -- HMAC-SHA256
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ, -- worker 处理成功时间
|
||||
reconcile_status TEXT NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
UNIQUE (event_id)
|
||||
);
|
||||
CREATE INDEX idx_im_billing_events_product ON im_billing_events(product_id, received_at DESC);
|
||||
CREATE INDEX idx_im_billing_events_user ON im_billing_events(user_id, received_at DESC);
|
||||
CREATE INDEX idx_im_billing_events_pending ON im_billing_events(reconcile_status) WHERE reconcile_status = 'pending';
|
||||
```
|
||||
|
||||
### 2.3 Webhook 端点
|
||||
|
||||
```python
|
||||
# app/api/market_billing_webhook.py
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
import hmac, hashlib, json
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/app/api/market/products", tags=["billing-webhook"])
|
||||
|
||||
|
||||
def verify_signature(product_id: str, body: bytes, signature: str) -> bool:
|
||||
"""HMAC-SHA256 验签。
|
||||
每个产品有独立 secret(存 market_products.metadata.billing_secret)。
|
||||
"""
|
||||
product = db.query(MarketProduct).filter(...).first()
|
||||
secret = (product.metadata or {}).get("billing_secret", "")
|
||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
@router.post("/{product_id}/billing-event")
|
||||
async def receive_billing_event(product_id: str, request: Request):
|
||||
body = await request.body()
|
||||
sig = request.headers.get("X-Billing-Signature", "")
|
||||
if not verify_signature(product_id, body, sig):
|
||||
raise HTTPException(401, "invalid signature")
|
||||
payload = json.loads(body)
|
||||
event_id = payload.get("event_id")
|
||||
if not event_id:
|
||||
raise HTTPException(400, "missing event_id")
|
||||
# 幂等:已有 event_id 直接返回
|
||||
existing = db.query(ImBillingEvent).filter(ImBillingEvent.event_id == event_id).first()
|
||||
if existing:
|
||||
return {"accepted": True, "event_id": existing.id, "dedup": True}
|
||||
# 落库
|
||||
evt = ImBillingEvent(
|
||||
event_id=event_id,
|
||||
product_id=product_id,
|
||||
user_id=payload.get("user_id"),
|
||||
event_type=payload["event_type"],
|
||||
amount=payload["amount"],
|
||||
raw_payload=payload,
|
||||
signature=sig,
|
||||
)
|
||||
db.add(evt); db.commit()
|
||||
return {"accepted": True, "event_id": evt.id}
|
||||
```
|
||||
|
||||
### 2.4 Reconcile Worker(异步)
|
||||
|
||||
```python
|
||||
# app/api/market_billing_worker.py
|
||||
import asyncio
|
||||
async def process_pending_events():
|
||||
"""每 30s 扫一次 reconcile_status='pending' 的事件,处理:
|
||||
- 'usage': 扣 user_balance.amount += amount
|
||||
- 'subscription_start': 创建 billing_subscription 行
|
||||
- 'subscription_end': 标记 subscription cancelled
|
||||
- 'refund': 反向扣减
|
||||
"""
|
||||
while True:
|
||||
events = db.query(ImBillingEvent).filter(
|
||||
ImBillingEvent.reconcile_status == "pending",
|
||||
ImBillingEvent.retry_count < 5,
|
||||
).limit(100).all()
|
||||
for evt in events:
|
||||
try:
|
||||
apply_to_user_balance(evt)
|
||||
evt.reconcile_status = "applied"
|
||||
evt.processed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
evt.retry_count += 1
|
||||
evt.error_message = str(e)
|
||||
db.commit()
|
||||
await asyncio.sleep(30)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 安全
|
||||
|
||||
| 项 | 实现 |
|
||||
|---|---|
|
||||
| 防重放 | event_id 唯一约束 |
|
||||
| 防伪造 | HMAC-SHA256(每产品独立 secret) |
|
||||
| 防篡改 | 数据库行级 hash(可选,`signature` + `body` 二次 hash) |
|
||||
| 防过载 | Rate limit:每产品 100 events/min |
|
||||
| 鉴权 | IP 白名单(可选,Casdoor OIDC token 也可) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 重试 / 死信
|
||||
|
||||
- 平台处理失败:`retry_count` 累加,`error_message` 记录
|
||||
- 5 次重试仍失败:`reconcile_status = 'dead_letter'`,后台告警
|
||||
- 产品方重发:用同一 `event_id`,平台识别为重复
|
||||
|
||||
---
|
||||
|
||||
## 5. 行动项
|
||||
|
||||
### 5.1 P1 — 落库 + 端点 stub(0.5 天)
|
||||
- 迁移 `im_billing_events` 表
|
||||
- 写 webhook 端点 + 幂等检查
|
||||
- 后台 admin 加 "Billing Events" 列表页
|
||||
|
||||
### 5.2 P2 — Worker + 计费对接(1 天)
|
||||
- 异步 reconcile worker(后台 task)
|
||||
- 余额扣减事务化(SQLAlchemy with FOR UPDATE)
|
||||
- 计费异常告警(Grafana + Alertmanager)
|
||||
|
||||
### 5.3 P3 — MuMuAINovel 接入(0.5 天)
|
||||
- 文档化 webhook 接入规范
|
||||
- MuMuAINovel 后端按规范加事件推送
|
||||
|
||||
**当前不实现任何代码,只保留设计 + 接口契约**。等 MuMuAINovel 上线后再拉 P1。
|
||||
Reference in new issue
Block a user