diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 6388c3b..c1bfdd4 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -306,6 +306,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv if payBase := os.Getenv("PAY_BASE_URL"); payBase != "" { paySystem := getenvDefault("PAY_BIZ_SYSTEM", "pangolin") paySecret := os.Getenv("PAY_BIZ_SECRET") + if paySecret == "" { + log.Fatal("PAY_BASE_URL 已设置但 PAY_BIZ_SECRET 为空,支付线将无法验签,拒绝启动") + } payClient := pay.NewClient(payBase, paySystem, paySecret) payStore := pay.NewStore(sqlDB) payHandler = pay.NewHandler(payClient, payStore, sqlDB) diff --git a/server/internal/pay/store.go b/server/internal/pay/store.go index 99b4585..f0bcdf0 100644 --- a/server/internal/pay/store.go +++ b/server/internal/pay/store.go @@ -78,12 +78,14 @@ func (s *Store) LockByOutTradeNoTx(ctx context.Context, tx *sql.Tx, outTradeNo s } // InsertFromWebhookTx 兜底补台账(下单后本地写失败的孤儿单,webhook 按 biz_ref 修复)。 +// 注意:webhookEvent payload 无 method 字段(用户选的支付方式,如 alipay/wxpay)可复原, +// 只有 channel(结算渠道);method 留空,不能拿 channel 冒充——语义不同,避免台账观感误导。 func (s *Store) InsertFromWebhookTx(ctx context.Context, tx *sql.Tx, userID int64, bizRef, sku, outTradeNo, channel string) (int64, error) { now := time.Now().UTC() res, err := tx.ExecContext(ctx, `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, channel, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?)`, - userID, bizRef, sku, outTradeNo, channel, channel, now, now) + userID, bizRef, sku, outTradeNo, "", channel, now, now) if err != nil { return 0, fmt.Errorf("pay.Store.InsertFromWebhookTx: %w", err) } diff --git a/server/internal/pay/testutil_test.go b/server/internal/pay/testutil_test.go index 7d1ea24..0e489c5 100644 --- a/server/internal/pay/testutil_test.go +++ b/server/internal/pay/testutil_test.go @@ -29,12 +29,19 @@ func openMigratedSQLite(t *testing.T) *sql.DB { // seedUser 造最小 users 行(列以 codes/sqlite_helper_test.go::seedUser 为准, // 实现时照抄那份 INSERT——含 uuid/email/pw_hash/dp_uuid 等 NOT NULL 列)。 func seedUser(t *testing.T, db *sql.DB, id int64, uuid string) { + t.Helper() + seedUserWithStatus(t, db, id, uuid, "active") +} + +// seedUserWithStatus 同 seedUser,可指定 status(如 'suspended')——用于测试兜底路径 +// 不得因用户被停用而拒绝补行开通(钱已实收,见 webhook.go settle 兜底分支的注释)。 +func seedUserWithStatus(t *testing.T, db *sql.DB, id int64, uuid, status string) { t.Helper() _, err := db.Exec( `INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at) - VALUES (?, ?, ?, 'x', ?, 'active', ?)`, - id, uuid, uuid+"@t.local", uuid, time.Now().UTC()) + VALUES (?, ?, ?, 'x', ?, ?, ?)`, + id, uuid, uuid+"@t.local", uuid, status, time.Now().UTC()) if err != nil { - t.Fatalf("seedUser: %v", err) + t.Fatalf("seedUserWithStatus: %v", err) } } diff --git a/server/internal/pay/webhook.go b/server/internal/pay/webhook.go index fd900a6..a91de4d 100644 --- a/server/internal/pay/webhook.go +++ b/server/internal/pay/webhook.go @@ -84,6 +84,7 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if ev.EventType != "payment.succeeded" { // 事件白名单外(pay 侧只应配 payment.succeeded):确认不处理,免重投。 + slog.Warn("pay webhook: 未知事件已 ack 未处理", "event_type", ev.EventType, "out_trade_no", ev.OutTradeNo) writeSuccess(w) return } @@ -141,8 +142,10 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error { switch { case err == sql.ErrNoRows: // 台账缺行(下单后本地写失败)→ 按 biz_ref=用户 uuid 兜底定位补建。 + // 与正常路径(row.UserID 直接开通,不看 user 状态)对齐:钱已实收, + // 兜底定位不应因用户被停用(suspended)而拒绝开通——否则静默丢钱。 if err := h.db.QueryRowContext(ctx, - `SELECT id FROM users WHERE uuid = ? AND status = 'active'`, ev.BizRef).Scan(&userID); err != nil { + `SELECT id FROM users WHERE uuid = ?`, ev.BizRef).Scan(&userID); err != nil { return fmt.Errorf("biz_ref %q 定位用户失败: %w", ev.BizRef, err) } purchaseID, err = h.store.InsertFromWebhookTx(ctx, tx, userID, ev.BizRef, ev.ProductBizCode, ev.OutTradeNo, ev.Channel) diff --git a/server/internal/pay/webhook_sqlite_test.go b/server/internal/pay/webhook_sqlite_test.go index 3963e57..5255d7b 100644 --- a/server/internal/pay/webhook_sqlite_test.go +++ b/server/internal/pay/webhook_sqlite_test.go @@ -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"))