优化回调推送:参考微信/支付宝机制,限制每商户单一回调地址
- 回调重试改为微信式固定退避序列(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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user