feat(v2): webhook 投递硬化——指数退避 + 最大次数死信 + 告警钩子(替裸 60s 猛敲)

This commit is contained in:
wangjia
2026-07-10 17:25:21 +08:00
parent 6427e8c848
commit c73f7ffaa9
5 changed files with 227 additions and 16 deletions
+45 -1
View File
@@ -2,6 +2,7 @@ package store
import (
"fmt"
"time"
"unicode/utf8"
"gorm.io/gorm"
@@ -67,7 +68,8 @@ func truncateUTF8(s string, n int) string {
}
// MarkFailed increments attempts and records the last error, leaving the row
// undelivered for the next retry sweep.
// undelivered for the next retry sweep. Superseded in production by
// ScheduleRetry/MarkDead (退避感知);kept for existing callers/tests.
func (s *WebhookStore) MarkFailed(id uint64, errMsg string) error {
errMsg = truncateUTF8(errMsg, 255)
if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id).
@@ -79,3 +81,45 @@ func (s *WebhookStore) MarkFailed(id uint64, errMsg string) error {
}
return nil
}
// ListDeliverable 取「未投递、未死信、且退避到点(next_attempt_at NULL 或 <= now)」的行。
func (s *WebhookStore) ListDeliverable(now time.Time, limit int) ([]WebhookDeliveryRow, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var out []WebhookDeliveryRow
if err := s.db.
Where("delivered = ? AND dead = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", false, false, now).
Order("id ASC").Limit(limit).Find(&out).Error; err != nil {
return nil, fmt.Errorf("store.ListDeliverable: %w", err)
}
return out, nil
}
// ScheduleRetry 记一次失败并排下次重试:attempts+1、last_error、next_attempt_at=nextAt。
func (s *WebhookStore) ScheduleRetry(id uint64, errMsg string, nextAt time.Time) error {
errMsg = truncateUTF8(errMsg, 255)
if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id).
Updates(map[string]any{
"attempts": gorm.Expr("attempts + 1"),
"last_error": errMsg,
"next_attempt_at": nextAt,
}).Error; err != nil {
return fmt.Errorf("store.ScheduleRetry: %w", err)
}
return nil
}
// MarkDead 达最大次数后放弃:attempts+1、dead=true、last_error。行留库供人工/对账排查。
func (s *WebhookStore) MarkDead(id uint64, errMsg string) error {
errMsg = truncateUTF8(errMsg, 255)
if err := s.db.Model(&model.WebhookDelivery{}).Where("id = ?", id).
Updates(map[string]any{
"attempts": gorm.Expr("attempts + 1"),
"dead": true,
"last_error": errMsg,
}).Error; err != nil {
return fmt.Errorf("store.MarkDead: %w", err)
}
return nil
}
+33
View File
@@ -2,6 +2,7 @@ package store_test
import (
"testing"
"time"
"unicode/utf8"
"github.com/wangjia/pay/internal/model"
@@ -104,3 +105,35 @@ func TestEnqueueDeliveryRefundIDUnique(t *testing.T) {
t.Fatalf("undelivered rows = %d want 3: %+v", len(rows), rows)
}
}
func TestWebhookScheduleRetryAndDead(t *testing.T) {
ws := store.NewWebhookStore(model.OpenTestDB(t))
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
_ = ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", "", `{"x":1}`)
// 刚入队:next_attempt_at NULL → 立即可投。
rows, _ := ws.ListDeliverable(now, 10)
if len(rows) != 1 {
t.Fatalf("新单应可投, got %d", len(rows))
}
id := rows[0].ID
// 排下一次重试到 now+30s:此刻不可投,过点可投。
if err := ws.ScheduleRetry(id, "http 500", now.Add(30*time.Second)); err != nil {
t.Fatalf("schedule: %v", err)
}
if r, _ := ws.ListDeliverable(now, 10); len(r) != 0 {
t.Fatalf("退避窗内不应可投, got %d", len(r))
}
if r, _ := ws.ListDeliverable(now.Add(31*time.Second), 10); len(r) != 1 || r[0].Attempts != 1 {
t.Fatalf("过退避点应可投且 attempts=1, got %+v", r)
}
// 标死信:不再出现在可投集。
if err := ws.MarkDead(id, "gave up"); err != nil {
t.Fatalf("markdead: %v", err)
}
if r, _ := ws.ListDeliverable(now.Add(time.Hour), 10); len(r) != 0 {
t.Fatalf("死信不应可投, got %d", len(r))
}
}