# 应用市场产品计费 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。