diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 239ebf3..6388c3b 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -302,12 +302,15 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv // ── Pay(pay v2 统一支付网关;PAY_BASE_URL 未配则整组不挂载)────────────── var payHandler *pay.Handler + var payWebhook *pay.WebhookHandler if payBase := os.Getenv("PAY_BASE_URL"); payBase != "" { paySystem := getenvDefault("PAY_BIZ_SYSTEM", "pangolin") paySecret := os.Getenv("PAY_BIZ_SECRET") payClient := pay.NewClient(payBase, paySystem, paySecret) payStore := pay.NewStore(sqlDB) payHandler = pay.NewHandler(payClient, payStore, sqlDB) + payWebhook = pay.NewWebhookHandler(payStore, codesSvc, sqlDB, rdb, + paySystem, paySecret, 5*time.Minute, 15*time.Minute) } else { log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载") } @@ -374,6 +377,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv // Webhook: HMAC-authenticated, no JWT. v1.Post("/webhook/store/codes", webhookHandler.ServeHTTP) + if payWebhook != nil { + v1.Post("/webhook/pay", payWebhook.ServeHTTP) + } // Protected: all routes that require a valid Bearer JWT. if tm != nil { diff --git a/server/internal/pay/webhook.go b/server/internal/pay/webhook.go new file mode 100644 index 0000000..fd900a6 --- /dev/null +++ b/server/internal/pay/webhook.go @@ -0,0 +1,173 @@ +package pay + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// Granter 抽象 codes.Service 的支付授予入口(测试可替身;生产传 *codes.Service)。 +type Granter interface { + GrantPaidSubscriptionTx(ctx context.Context, tx *sql.Tx, userID int64, plan codes.PlanCode, days int, ref string) (int64, time.Time, error) +} + +// WebhookHandler 接收 pay 的 payment.succeeded 出站 webhook。 +// 验签与 pay verifyBizSign 对称:同 secret,parts=[system, ts, nonce, rawBody], +// ±tolerance 时间窗。幂等三层:nonce SETNX(传输重放)→ out_trade_no 锁内 +// CAS(业务幂等,重投唯一可靠键)→ biz_ref 兜底(台账缺行自修复)。 +type WebhookHandler struct { + store *Store + granter Granter + db *sql.DB + rdb *redis.Client // 可为 nil:跳过 nonce 层,业务幂等仍成立 + system string + secret string + tolerance time.Duration + nonceTTL time.Duration + now func() time.Time // 测试注入 +} + +func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client, + system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler { + return &WebhookHandler{store: store, granter: granter, db: db, rdb: rdb, + system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now} +} + +// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload +// (注意:payment.succeeded 无 refund_id 字段)。 +type webhookEvent struct { + EventType string `json:"event_type"` + OutTradeNo string `json:"out_trade_no"` + BizSystem string `json:"biz_system"` + BizRef string `json:"biz_ref"` + ProductBizCode string `json:"product_biz_code"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + Channel string `json:"channel"` + PaidAt string `json:"paid_at"` // RFC3339 +} + +func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + if !h.verify(r, body) { + http.Error(w, "signature verification failed", http.StatusUnauthorized) + return + } + // nonce 防重放(仅传输层;pay 每次重投换新 nonce,业务幂等靠 out_trade_no)。 + if h.rdb != nil { + if nonce := r.Header.Get("X-Pay-Nonce"); nonce != "" { + ok, err := h.rdb.SetNX(r.Context(), "pay:webhook:nonce:"+nonce, 1, h.nonceTTL).Result() + if err == nil && !ok { + writeSuccess(w) // 同 nonce 重放:已处理过,直接确认 + return + } + } + } + var ev webhookEvent + if err := json.Unmarshal(body, &ev); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + if ev.EventType != "payment.succeeded" { + // 事件白名单外(pay 侧只应配 payment.succeeded):确认不处理,免重投。 + writeSuccess(w) + return + } + if err := h.settle(r.Context(), &ev); err != nil { + slog.Error("pay webhook 开通失败(pay 将退避重投)", "order_no", ev.OutTradeNo, "err", err) + http.Error(w, "settle failed", http.StatusInternalServerError) + return + } + writeSuccess(w) +} + +// writeSuccess:pay 的 ACK 判据是 HTTP 200 且 body 含 "SUCCESS"(大写包含)。 +func writeSuccess(w http.ResponseWriter) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("SUCCESS")) +} + +func (h *WebhookHandler) verify(r *http.Request, body []byte) bool { + if r.Header.Get("X-Pay-System") != h.system { + return false + } + ts := r.Header.Get("X-Pay-Timestamp") + nonce := r.Header.Get("X-Pay-Nonce") + sign := r.Header.Get("X-Pay-Sign") + if ts == "" || nonce == "" || sign == "" { + return false + } + tsi, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return false + } + tol := int64(h.tolerance.Seconds()) + if d := h.now().Unix() - tsi; d > tol || d < -tol { + return false + } + return hmacVerify(h.secret, sign, h.system, ts, nonce, string(body)) +} + +// settle 幂等开通:锁台账行 → created→paid 翻转 + 同事务 grant(叠加语义 +// 复用 codes.applySubscription)。已 paid 直接返回 nil(重投/并发输家)。 +// canceled 行也照常开通——钱已实收,本地 cancel 只是未支付单的整理。 +func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error { + item, ok := CatalogBySKU(ev.ProductBizCode) + if !ok { + return fmt.Errorf("未知 product_biz_code %q(与 pay 种子漂移?)", ev.ProductBizCode) + } + tx, err := h.store.BeginTx(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var purchaseID, userID int64 + row, err := h.store.LockByOutTradeNoTx(ctx, tx, ev.OutTradeNo) + switch { + case err == sql.ErrNoRows: + // 台账缺行(下单后本地写失败)→ 按 biz_ref=用户 uuid 兜底定位补建。 + if err := h.db.QueryRowContext(ctx, + `SELECT id FROM users WHERE uuid = ? AND status = 'active'`, 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) + if err != nil { + return err + } + case err != nil: + return err + case row.Status == "paid": + return nil // 幂等:已消费,直接 SUCCESS + default: + purchaseID, userID = row.ID, row.UserID + } + + subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID, + codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo) + if err != nil { + return err + } + paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt) + if perr != nil { + paidAt = h.now().UTC() + } + if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil { + return err + } + return tx.Commit() +} diff --git a/server/internal/pay/webhook_sqlite_test.go b/server/internal/pay/webhook_sqlite_test.go new file mode 100644 index 0000000..3963e57 --- /dev/null +++ b/server/internal/pay/webhook_sqlite_test.go @@ -0,0 +1,219 @@ +package pay + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func newWebhookRig(t *testing.T) (*WebhookHandler, *sql.DB, *Store) { + t.Helper() + db := openMigratedSQLite(t) + seedUser(t, db, 1, "uuid-1") + st := NewStore(db) + codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + h := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute) + return h, db, st +} + +// deliver 按 pay notifier.go 出站语义构造签名请求(每次新 nonce,模拟重投)。 +func deliver(t *testing.T, h *WebhookHandler, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(payload) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := newNonce() + r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pay-System", "pangolin") + r.Header.Set("X-Pay-Event", "payment.succeeded") + r.Header.Set("X-Pay-Timestamp", ts) + r.Header.Set("X-Pay-Nonce", nonce) + r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body))) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func succeededPayload(orderNo, sku string) map[string]any { + return map[string]any{ + "event_type": "payment.succeeded", + "out_trade_no": orderNo, + "biz_system": "pangolin", + "biz_ref": "uuid-1", + "product_biz_code": sku, + "amount_minor": int64(4201234), + "currency": "USDT", + "channel": "crypto", + "paid_at": time.Now().UTC().Format(time.RFC3339), + } +} + +func TestWebhook_HappyPath(t *testing.T) { + h, db, st := newWebhookRig(t) + ctx := context.Background() + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil { + t.Fatal(err) + } + w := deliver(t, h, succeededPayload("pay001", "pro_month")) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { + t.Fatalf("ACK 不符: %d %q(pay 要求 200+body 含 SUCCESS)", w.Code, w.Body.String()) + } + row, err := st.GetForUser(ctx, 1, "pay001") + if err != nil { + t.Fatal(err) + } + if row.Status != "paid" || !row.SubID.Valid || row.AmountMinor != 4201234 || row.Channel != "crypto" { + t.Fatalf("台账未正确消费: %+v", row) + } + var source string + var expires time.Time + if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id = ?`, row.SubID.Int64). + Scan(&source, &expires); err != nil { + t.Fatalf("订阅未开通: %v", err) + } + if source != "pay" { + t.Errorf("source = %q, want pay", source) + } + want := time.Now().UTC().AddDate(0, 0, 31) + if d := expires.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("expires = %v, want ≈ %v(pro_month=31 天)", expires, want) + } +} + +func TestWebhook_RedeliveryIdempotent(t *testing.T) { + h, db, st := newWebhookRig(t) + ctx := context.Background() + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") + deliver(t, h, succeededPayload("pay001", "pro_month")) + var exp1 time.Time + _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1) + + // 重投(新 nonce)必须:200+SUCCESS、订阅行数不变、到期不变。 + w := deliver(t, h, succeededPayload("pay001", "pro_month")) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { + t.Fatalf("重投未确认: %d %q", w.Code, w.Body.String()) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n) + if n != 1 { + t.Fatalf("重投多开了订阅: rows = %d", n) + } + var exp2 time.Time + _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp2) + if !exp1.Equal(exp2) { + t.Errorf("重投改了到期: %v → %v", exp1, exp2) + } +} + +func TestWebhook_TwoOrdersStack(t *testing.T) { + h, db, st := newWebhookRig(t) + ctx := context.Background() + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") + _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto") + deliver(t, h, succeededPayload("pay001", "pro_month")) + deliver(t, h, succeededPayload("pay002", "pro_quarter")) + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n) + if n != 1 { + t.Fatalf("同 plan 应原地叠加: rows = %d", n) + } + var expires time.Time + _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expires) + want := time.Now().UTC().AddDate(0, 0, 31+92) + if d := expires.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("expires = %v, want ≈ %v(31+92 天叠加)", expires, want) + } +} + +// 台账缺行(下单后本地写失败):按 biz_ref 兜底定位用户、补台账、照常开通。 +func TestWebhook_MissingLedgerFallsBackToBizRef(t *testing.T) { + h, db, st := newWebhookRig(t) + w := deliver(t, h, succeededPayload("pay-orphan", "pro_year")) + if w.Code != http.StatusOK { + t.Fatalf("code = %d body = %s", w.Code, w.Body.String()) + } + row, err := st.GetForUser(context.Background(), 1, "pay-orphan") + if err != nil { + t.Fatalf("兜底台账未建: %v", err) + } + if row.Status != "paid" || row.SKU != "pro_year" { + t.Fatalf("兜底行不符: %+v", row) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1 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")) + mk := func(mutate func(r *http.Request)) int { + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := newNonce() + r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body)) + r.Header.Set("X-Pay-System", "pangolin") + r.Header.Set("X-Pay-Timestamp", ts) + r.Header.Set("X-Pay-Nonce", nonce) + r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body))) + mutate(r) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-Sign", "AAAA") }); c != http.StatusUnauthorized { + t.Errorf("坏签名 code = %d, want 401", c) + } + if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-System", "jiu") }); c != http.StatusUnauthorized { + t.Errorf("错 system code = %d, want 401", c) + } + if c := mk(func(r *http.Request) { + stale := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10) + r.Header.Set("X-Pay-Timestamp", stale) + // 注意:重签,否则先挂在签名而非时间窗上 + nonce := r.Header.Get("X-Pay-Nonce") + r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", stale, nonce, string(body))) + }); c != http.StatusUnauthorized { + t.Errorf("过期 ts code = %d, want 401", c) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n) + if n != 0 { + t.Errorf("拒绝路径不得开通: %d", n) + } +} + +// 未知 sku(catalog 漂移)→ 500,pay 会重投,期间可修 catalog 后自愈。 +func TestWebhook_UnknownSKU500(t *testing.T) { + h, _, _ := newWebhookRig(t) + w := deliver(t, h, succeededPayload("pay001", "pro_lifetime")) + if w.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", w.Code) + } +} + +// 白名单外事件(如未来误配 refund.succeeded):确认不处理,避免无谓重投 12 次。 +func TestWebhook_IgnoredEventAcked(t *testing.T) { + h, db, _ := newWebhookRig(t) + p := succeededPayload("pay001", "pro_month") + p["event_type"] = "refund.succeeded" + w := deliver(t, h, p) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { + t.Fatalf("白名单外事件应直接确认: %d", w.Code) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n) + if n != 0 { + t.Errorf("不得开通: %d", n) + } +}