fix(server): pay webhook 兜底补行不再漏停用用户(修静默丢钱)+ 未知事件日志 + PAY_BIZ_SECRET 启动守卫

- webhook 兜底路径(台账缺行按 biz_ref 定位用户)去掉 status='active' 过滤,
  与正常路径(row.UserID 直接开通不看状态)对齐:钱已实收,不因用户被停用
  (banned)而拒绝补行开通,避免 500→pay 12 次重投后死信→静默丢钱。
- 未知 event_type 分支加 slog.Warn,便于将来 pay 侧误注册无 handler 的事件
  类型时能被观测到。
- PAY_BASE_URL 已设但 PAY_BIZ_SECRET 为空时 log.Fatal 拒绝启动,避免出站
  签名失败+入站验签全 401 的静默瘫痪。
- InsertFromWebhookTx 不再把 channel 冒充 method 写入台账(payload 无 method
  字段可复原,留空并加注释,消除台账观感误导)。

新增 TestWebhook_MissingLedgerFallsBackEvenWhenUserSuspended 覆盖 #1:
台账缺行 + 目标用户 banned 时,兜底补行仍成功开通并回 SUCCESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-11 00:48:57 +08:00
parent 390b8b8f84
commit bc9aa38392
5 changed files with 52 additions and 5 deletions
@@ -155,6 +155,38 @@ func TestWebhook_MissingLedgerFallsBackToBizRef(t *testing.T) {
}
}
// 【必修·钱安全回归】台账缺行 + 目标用户已被停用(banned,如下单后被封):
// 钱已实收,兜底路径仍须补行开通并回 SUCCESS,不能因 status 过滤而 500→死信丢钱。
func TestWebhook_MissingLedgerFallsBackEvenWhenUserSuspended(t *testing.T) {
h, db, st := newWebhookRig(t)
// newWebhookRig 已 seedUser(id=1, uuid-1, active);这里另建一个非 active(banned)用户。
seedUserWithStatus(t, db, 2, "uuid-2", "banned")
p := succeededPayload("pay-suspended", "pro_year")
p["biz_ref"] = "uuid-2"
w := deliver(t, h, p)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") {
t.Fatalf("停用用户兜底补行应仍成功: code=%d body=%q(未修复前会 500 并被 pay 判死信,静默丢钱)", w.Code, w.Body.String())
}
row, err := st.GetForUser(context.Background(), 2, "pay-suspended")
if err != nil {
t.Fatalf("兜底台账未建: %v", err)
}
if row.Status != "paid" || row.SKU != "pro_year" {
t.Fatalf("兜底行不符: %+v", row)
}
if row.Method != "" {
t.Errorf("method = %q, want 空(payload 无 method,不该拿 channel 冒充)", row.Method)
}
if row.Channel != "crypto" {
t.Errorf("channel = %q, want crypto", row.Channel)
}
var n int
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 2 AND source = 'pay'`).Scan(&n)
if n != 1 {
t.Errorf("订阅未开通: %d", n)
}
}
func TestWebhook_RejectsBadSignatureAndStaleTimestamp(t *testing.T) {
h, db, _ := newWebhookRig(t)
body, _ := json.Marshal(succeededPayload("pay001", "pro_month"))