feat(v2): webhook 投递硬化——指数退避 + 最大次数死信 + 告警钩子(替裸 60s 猛敲)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user