Skip to content

Commit 6a1d8b8

Browse files
committed
feat(ai-gateway): webhook 支付确认后自动绑定用户(支付→激活闭环)
1 parent cd9d812 commit 6a1d8b8

4 files changed

Lines changed: 216 additions & 10 deletions

File tree

‎server/ai-gateway/routers/license_webhook.py‎

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
熵减 AI 网关 — 面包多订单通知 Webhook
33
44
@ai-context: 接收支付平台订单通知:快速应答 200 → 异步验真(HMAC 签名 +
5-
order_id 查询确认双保险)→ 标记激活码 sold。重复通知幂等(状态更新幂等)。
5+
order_id 查询确认双保险)→ 标记激活码 sold → 若回调携带用户标识
6+
(from_user/buyer_email) 自动完成 sold → bound 绑定 + 更新 paid metadata,
7+
实现「支付→自动激活」闭环。重复通知幂等(状态更新幂等,已 bound 不再重复绑)。
68
验真失败/平台不可用时入队 pending(内存队列 + 日志告警),由
7-
scripts/license-admin.mjs reconcile 人工对账兜底。
9+
scripts/license-admin.mjs reconcile 人工对账兜底。自动绑定失败不阻塞订单
10+
确认(仅记日志),用户可回落手动输入激活码流程。
811
@ai-context: 本端点不要求用户登录(平台服务端回调),不注册限流(依赖验真
912
而非频率限制防伪造);原始 payload 落日志供排查。
1013
"""
@@ -60,14 +63,36 @@ async def _handle_webhook(payload: dict[str, Any], signature: str | None = None)
6063
# 查询确认(主闸):凭 order_id 主动查询订单真实状态
6164
ok, reason = await payment_adapter.verify_and_mark_sold(order_id, code)
6265

63-
if ok:
64-
return {"code": 200, "message": "ok"}
65-
if sig_ok and reason.startswith("订单未支付"):
66-
# 签名有效但订单未支付:正常拒绝,不入队(不是系统故障)
67-
return {"code": 200, "message": reason}
68-
# 查询失败/平台不可用/验真失败:入队待对账,先应答 200 避免重试风暴
69-
_enqueue_pending(order_id, reason)
70-
return {"code": 200, "message": "accepted"}
66+
if not ok:
67+
if sig_ok and reason.startswith("订单未支付"):
68+
# 签名有效但订单未支付:正常拒绝,不入队(不是系统故障)
69+
return {"code": 200, "message": reason}
70+
# 查询失败/平台不可用/验真失败:入队待对账,先应答 200 避免重试风暴
71+
_enqueue_pending(order_id, reason)
72+
return {"code": 200, "message": "accepted"}
73+
74+
# ✅ 订单确认 sold 成功后,尝试自动绑定用户(支付→激活闭环)
75+
buyer = payment_adapter.extract_buyer_info(payload)
76+
if buyer.get("user_id"):
77+
from services.supabase_adapter import auto_bind_license
78+
79+
bound_ok = await auto_bind_license(code, buyer["user_id"])
80+
if bound_ok:
81+
logger.info("Webhook 自动绑定成功: code=%s, user=%s", (code or "")[:12] + "...", buyer["user_id"])
82+
else:
83+
logger.warning("Webhook 自动绑定失败(状态非 sold 或绑定失败): code=%s", (code or "")[:12] + "...")
84+
elif buyer.get("email"):
85+
# 通过邮箱查询 user_id 后尝试自动绑定
86+
from services.supabase_adapter import auto_bind_license, get_user_by_email
87+
88+
user = await get_user_by_email(buyer["email"])
89+
user_id = (user or {}).get("id") or (user or {}).get("user_id")
90+
if user_id:
91+
bound_ok = await auto_bind_license(code, user_id)
92+
if bound_ok:
93+
logger.info("Webhook 通过 email 自动绑定成功: code=%s, user=%s", code[:12] + "...", user_id)
94+
95+
return {"code": 200, "message": "ok"}
7196

7297

7398
@router.post("/webhook")

‎server/ai-gateway/services/payment_adapter.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,31 @@ async def verify_and_mark_sold(order_id: str, code: str | None = None) -> tuple[
193193

194194
logger.info("订单已确认并标记 sold: order=%s, code=%s", order_id, sold_code[:12] + "...")
195195
return True, "ok"
196+
197+
198+
# ============================================================
199+
# 买家信息提取(Webhook 自动绑定闭环)
200+
# ============================================================
201+
202+
203+
def extract_buyer_info(payload: dict) -> dict:
204+
"""从 webhook payload 提取用户标识信息。
205+
206+
面包多卡密模式的自定义参数通过回调参数回传(from_user / buyer_email)。
207+
缺失时返回空 dict,调用方回落手动激活码模式。
208+
209+
@ai-context: 用户标识不参与签名验真(平台回调原样透传),因此自动绑定
210+
仅作为体验增强:绑定失败不阻塞订单确认,回落手动激活码流程兜底。
211+
212+
Args:
213+
payload: 解析后的 webhook 请求体
214+
215+
Returns:
216+
dict: {"user_id": str, "email": str},均为空串表示无用户标识
217+
"""
218+
from_user = payload.get("from_user") or ""
219+
email = payload.get("buyer_email") or payload.get("email") or ""
220+
return {
221+
"user_id": from_user,
222+
"email": email,
223+
}

‎server/ai-gateway/services/supabase_adapter.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,81 @@ async def update_paid_metadata(user_id: str, paid: dict[str, Any]) -> bool:
217217
json_body={"user_metadata": {"paid": paid}},
218218
)
219219
return bool(data)
220+
221+
222+
# ============================================================
223+
# Webhook 自动绑定闭环(支付 → 自动激活)
224+
# ============================================================
225+
226+
227+
async def get_user_by_email(email: str) -> dict | None:
228+
"""通过邮箱查询 Supabase 用户(Auth Admin API)。
229+
230+
@ai-context: webhook 携带 buyer_email 时,需要先解析出 user_id 才能写入
231+
user_metadata.paid;查询失败返回 None,调用方跳过自动绑定回落手动激活。
232+
233+
Args:
234+
email: 买家邮箱
235+
236+
Returns:
237+
dict | None: 用户对象(含 id),未找到/查询失败返回 None
238+
"""
239+
if _is_mock_mode():
240+
return _mock_metadata.get(email)
241+
data = await _http_request(
242+
"GET",
243+
f"{_config()[0]}/auth/v1/admin/users?email={email}",
244+
)
245+
if not data:
246+
return None
247+
if isinstance(data, list):
248+
return data[0] if data else None
249+
# Supabase Auth Admin API 返回 {"users": [...]} 结构,取首个匹配用户
250+
if isinstance(data, dict) and data.get("users"):
251+
return data["users"][0]
252+
return data
253+
254+
255+
async def auto_bind_license(code: str, user_id: str) -> bool:
256+
"""Webhook 确认后自动绑定许可证(sold → bound)+ 更新 paid metadata。
257+
258+
这是「支付→自动激活」闭环的核心:无需用户手动输入激活码。
259+
返回 False 表示激活码不存在、状态不是 sold 或绑定失败。
260+
261+
@ai-context: 自动绑定为幂等增强:绑定失败不抛异常、不阻塞订单确认,
262+
由调用方记录日志,用户可回落手动输入激活码流程。
263+
264+
Args:
265+
code: 激活码(webhook 已确认订单)
266+
user_id: 买家用户 ID
267+
268+
Returns:
269+
bool: 是否绑定成功
270+
"""
271+
from datetime import datetime, timedelta, timezone
272+
273+
row = await get_license_by_code(code)
274+
if not row:
275+
return False
276+
277+
if row.get("status") != "sold":
278+
logger.warning("自动绑定拒绝: 状态不是 sold (code=%s, status=%s)", code[:12], row.get("status"))
279+
return False
280+
281+
duration = row.get("duration_days", 30)
282+
now = datetime.now(timezone.utc)
283+
expires_at = (now + timedelta(days=duration)).isoformat()
284+
285+
bound = await bind_license(code, user_id, "auto-webhook", expires_at)
286+
if not bound:
287+
return False
288+
289+
tier = "lifetime" if row.get("type") == "lifetime" else "pro"
290+
paid = {
291+
"tier": tier,
292+
"expires_at": expires_at if tier == "pro" else None,
293+
"updated_at": now.isoformat(),
294+
}
295+
await update_paid_metadata(user_id, paid)
296+
297+
return True

‎server/ai-gateway/tests/test_payment_webhook.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- 幂等:重复通知仍 200(不重复入队不报错)
88
- 降级:查询失败 → 入队 pending,响应 200
99
- 订单未支付(签名有效时)→ 正常拒绝不入队
10+
- 自动绑定:携带 from_user → sold → bound + paid metadata;无用户标识 → 仅 sold
1011
"""
1112

1213
import hashlib
@@ -145,3 +146,77 @@ async def test_missing_order_id_rejected(self):
145146

146147
result = await _handle_webhook({})
147148
assert result["code"] == 400
149+
150+
151+
class TestAutoBind:
152+
"""webhook 自动绑定用户(支付→激活闭环)"""
153+
154+
@pytest.mark.asyncio
155+
async def test_webhook_with_from_user_auto_binds(self):
156+
"""携带 from_user 的 webhook 自动完成 sold → bound + paid metadata"""
157+
from routers.license_webhook import _handle_webhook
158+
from services import payment_adapter, supabase_adapter
159+
160+
_seed_pool()
161+
payment_adapter._seed_mock_order("MB-2001", "ENTROPY-PRO-AAAA-BBBB", status="paid")
162+
163+
result = await _handle_webhook({
164+
"order_id": "MB-2001",
165+
"code": "ENTROPY-PRO-AAAA-BBBB",
166+
"from_user": "user-123",
167+
})
168+
assert result["code"] == 200
169+
assert result["message"] == "ok"
170+
171+
# 自动绑定:sold → bound
172+
row = supabase_adapter._mock_pool["ENTROPY-PRO-AAAA-BBBB"]
173+
assert row["status"] == "bound"
174+
assert row["bound_user_id"] == "user-123"
175+
assert row["machine_id"] == "auto-webhook"
176+
177+
# paid metadata 已更新(mock 模式直接存 paid dict:tier/expires_at/updated_at)
178+
meta = supabase_adapter._mock_metadata.get("user-123")
179+
assert meta is not None
180+
assert meta["tier"] == "pro"
181+
assert meta["expires_at"] is not None
182+
183+
@pytest.mark.asyncio
184+
async def test_webhook_without_from_user_no_bind(self):
185+
"""无用户标识的 webhook 仅标记 sold,不自动绑定(兼容手动激活码)"""
186+
from routers.license_webhook import _handle_webhook
187+
from services import payment_adapter, supabase_adapter
188+
189+
_seed_pool()
190+
payment_adapter._seed_mock_order("MB-2002", "ENTROPY-PRO-AAAA-BBBB", status="paid")
191+
192+
result = await _handle_webhook({
193+
"order_id": "MB-2002",
194+
"code": "ENTROPY-PRO-AAAA-BBBB",
195+
})
196+
assert result["code"] == 200
197+
198+
# 仅 sold,未绑定(mock 记录中 bound_user_id/machine_id 保持 None)
199+
row = supabase_adapter._mock_pool["ENTROPY-PRO-AAAA-BBBB"]
200+
assert row["status"] == "sold"
201+
assert row["bound_user_id"] is None
202+
assert row["machine_id"] is None
203+
204+
@pytest.mark.asyncio
205+
async def test_webhook_with_unknown_email_no_bind(self):
206+
"""携带 email 但未匹配到用户 → 跳过绑定不崩溃(回落手动激活)"""
207+
from routers.license_webhook import _handle_webhook
208+
from services import payment_adapter, supabase_adapter
209+
210+
_seed_pool()
211+
payment_adapter._seed_mock_order("MB-2003", "ENTROPY-PRO-AAAA-BBBB", status="paid")
212+
213+
result = await _handle_webhook({
214+
"order_id": "MB-2003",
215+
"code": "ENTROPY-PRO-AAAA-BBBB",
216+
"buyer_email": "buyer@example.com",
217+
})
218+
assert result["code"] == 200
219+
220+
row = supabase_adapter._mock_pool["ENTROPY-PRO-AAAA-BBBB"]
221+
assert row["status"] == "sold"
222+
assert row["bound_user_id"] is None

0 commit comments

Comments
 (0)