bc9aa38392
- 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
252 lines
9.0 KiB
Go
252 lines
9.0 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// 【必修·钱安全回归】台账缺行 + 目标用户已被停用(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"))
|
|
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)
|
|
}
|
|
}
|