优化回调推送:参考微信/支付宝机制,限制每商户单一回调地址

- 回调重试改为微信式固定退避序列(15s/15s/30s/3m/10m/20m/30m/30m/30m/1h/3h/3h/3h/6h/6h),共 16 次尝试
- 重试次数/推送超时/退避序列可通过环境变量配置(CALLBACK_MAX_ATTEMPTS/CALLBACK_PUSH_TIMEOUT_SECONDS/CALLBACK_RETRY_SCHEDULE)
- 每个商户仅允许 1 个 active 回调订阅,创建前校验,先停用后新建
- 前端回调页提示推送策略与单地址限制
- docker-compose 透传回调相关配置
This commit is contained in:
yml2213
2026-08-03 10:57:21 +08:00
parent 7fe4a05ed7
commit cf2ee96baa
5 changed files with 131 additions and 15 deletions
+78 -13
View File
@@ -23,21 +23,74 @@ import (
"gorm.io/gorm"
)
const maxCallbackAttempts = 8
// 回调推送策略(参考微信/支付宝通知机制):
// 首次推送失败后按固定序列退避重试,默认共 16 次尝试,之后标记 failed 不再推送。
// 序列与总次数均可通过环境变量覆盖(CALLBACK_RETRY_SCHEDULE / CALLBACK_MAX_ATTEMPTS)。
var defaultCallbackRetrySchedule = []time.Duration{
15 * time.Second, // 第 2 次
15 * time.Second, // 第 3 次
30 * time.Second, // 第 4 次
3 * time.Minute, // 第 5 次
10 * time.Minute, // 第 6 次
20 * time.Minute, // 第 7 次
30 * time.Minute, // 第 8 次
30 * time.Minute, // 第 9 次
30 * time.Minute, // 第 10 次
60 * time.Minute, // 第 11 次
3 * time.Hour, // 第 12 次
3 * time.Hour, // 第 13 次
3 * time.Hour, // 第 14 次
6 * time.Hour, // 第 15 次
6 * time.Hour, // 第 16 次
}
// CallbackConfig 回调推送策略配置。
type CallbackConfig struct {
MaxAttempts int
PushTimeout time.Duration
// RetrySchedule 第 N 次重试前的等待间隔;不足时复用最后一个间隔。
RetrySchedule []time.Duration
}
// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
type CallbackService struct {
db *gorm.DB
codec *SecretCodec
httpClient *http.Client
maxAttempts int
retrySchedule []time.Duration
}
func NewCallbackService(db *gorm.DB, codec *SecretCodec) *CallbackService {
return &CallbackService{
db: db,
codec: codec,
httpClient: &http.Client{Timeout: 10 * time.Second},
func NewCallbackService(db *gorm.DB, codec *SecretCodec, opts ...func(*CallbackConfig)) *CallbackService {
cfg := CallbackConfig{
MaxAttempts: 16,
PushTimeout: 15 * time.Second,
RetrySchedule: defaultCallbackRetrySchedule,
}
for _, opt := range opts {
opt(&cfg)
}
if cfg.MaxAttempts < 1 {
cfg.MaxAttempts = 16
}
if cfg.PushTimeout <= 0 {
cfg.PushTimeout = 15 * time.Second
}
if len(cfg.RetrySchedule) == 0 {
cfg.RetrySchedule = defaultCallbackRetrySchedule
}
return &CallbackService{
db: db,
codec: codec,
httpClient: &http.Client{Timeout: cfg.PushTimeout},
maxAttempts: cfg.MaxAttempts,
retrySchedule: cfg.RetrySchedule,
}
}
// WithCallbackConfig 便捷的配置选项,供 main 装配。
func WithCallbackConfig(cfg CallbackConfig) func(*CallbackConfig) {
return func(target *CallbackConfig) { *target = cfg }
}
func (s *CallbackService) SetHTTPClient(client *http.Client) {
@@ -70,6 +123,16 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
if len(events) == 0 {
return nil, errors.New("至少订阅一个事件")
}
// 每个商户仅允许一个回调地址:存在 active 订阅时拒绝创建,先停用旧的再新建。
var activeCount int64
if err := s.db.Model(&model.CallbackSubscription{}).
Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).
Count(&activeCount).Error; err != nil {
return nil, err
}
if activeCount > 0 {
return nil, errors.New("每个商户仅支持一个回调地址,请先停用当前订阅再创建")
}
secret, err := randomToken("cb_", 32)
if err != nil {
return nil, err
@@ -260,8 +323,8 @@ func (s *CallbackService) dispatchOne(ctx context.Context, id uint) (bool, bool,
func (s *CallbackService) recordCallbackFailure(delivery model.CallbackDelivery, statusCode int, response string) error {
attempts := delivery.Attempts + 1
status := model.CallbackDeliveryPending
nextAttempt := time.Now().Add(callbackRetryDelay(attempts))
if attempts >= maxCallbackAttempts {
nextAttempt := time.Now().Add(s.callbackRetryDelay(attempts))
if attempts >= s.maxAttempts {
status = model.CallbackDeliveryFailed
nextAttempt = time.Now()
}
@@ -321,15 +384,17 @@ func validateCallbackURL(rawURL string) error {
return nil
}
func callbackRetryDelay(attempts int) time.Duration {
// callbackRetryDelay 返回第 attempts 次失败后的重试间隔(微信式固定序列)。
// 序列用尽后复用最后一个间隔。
func (s *CallbackService) callbackRetryDelay(attempts int) time.Duration {
if attempts < 1 {
attempts = 1
}
delay := time.Second * time.Duration(1<<(attempts-1))
if delay > 15*time.Minute {
return 15 * time.Minute
idx := attempts - 1
if idx >= len(s.retrySchedule) {
idx = len(s.retrySchedule) - 1
}
return delay
return s.retrySchedule[idx]
}
func truncateCallbackError(value string) string {