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

- 回调重试改为微信式固定退避序列(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
+17 -1
View File
@@ -20,6 +20,18 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// secondsToDurations 把秒数列表转成 time.Duration 列表(回调重试序列)。
func secondsToDurations(seconds []int) []time.Duration {
if len(seconds) == 0 {
return nil
}
out := make([]time.Duration, 0, len(seconds))
for _, s := range seconds {
out = append(out, time.Duration(s)*time.Second)
}
return out
}
func main() { func main() {
cfg := config.Load() cfg := config.Load()
time.Local = timeutil.Location() time.Local = timeutil.Location()
@@ -54,7 +66,11 @@ func main() {
tenantSvc := service.NewTenantService(db) tenantSvc := service.NewTenantService(db)
authSvc := service.NewAuthService(db, jm, tenantSvc) authSvc := service.NewAuthService(db, jm, tenantSvc)
userSvc := service.NewUserService(db, tenantSvc) userSvc := service.NewUserService(db, tenantSvc)
callbackSvc := service.NewCallbackService(db, codec) callbackSvc := service.NewCallbackService(db, codec, service.WithCallbackConfig(service.CallbackConfig{
MaxAttempts: cfg.CallbackMaxAttempts,
PushTimeout: time.Duration(cfg.CallbackPushTimeoutSeconds) * time.Second,
RetrySchedule: secondsToDurations(cfg.CallbackRetryScheduleSeconds),
}))
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
merchantSvc := service.NewMerchantService(db, codec, tenantSvc) merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
deliverySvc := service.NewDeliveryService( deliverySvc := service.NewDeliveryService(
+30
View File
@@ -40,6 +40,13 @@ type Config struct {
FulfillmentProcessingTimeoutMinutes int FulfillmentProcessingTimeoutMinutes int
// FulfillmentTimeoutScanIntervalSeconds 发货超时巡检间隔秒数。 // FulfillmentTimeoutScanIntervalSeconds 发货超时巡检间隔秒数。
FulfillmentTimeoutScanIntervalSeconds int FulfillmentTimeoutScanIntervalSeconds int
// CallbackMaxAttempts 回调推送总尝试次数(首次 + 重试),默认 16(微信式)。
CallbackMaxAttempts int
// CallbackPushTimeoutSeconds 单次回调推送 HTTP 超时秒数。
CallbackPushTimeoutSeconds int
// CallbackRetryScheduleSeconds 回调失败重试间隔序列(秒,逗号分隔,微信/支付宝式固定退避)。
// 为空时使用默认微信式序列。
CallbackRetryScheduleSeconds []int
} }
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高) // Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
@@ -66,9 +73,32 @@ func Load() *Config {
DeliveryLinkTTLMinutes: getEnvInt("DELIVERY_LINK_TTL_MINUTES", 120), DeliveryLinkTTLMinutes: getEnvInt("DELIVERY_LINK_TTL_MINUTES", 120),
FulfillmentProcessingTimeoutMinutes: getEnvInt("FULFILLMENT_PROCESSING_TIMEOUT_MINUTES", 30), FulfillmentProcessingTimeoutMinutes: getEnvInt("FULFILLMENT_PROCESSING_TIMEOUT_MINUTES", 30),
FulfillmentTimeoutScanIntervalSeconds: getEnvInt("FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS", 60), FulfillmentTimeoutScanIntervalSeconds: getEnvInt("FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS", 60),
CallbackMaxAttempts: getEnvInt("CALLBACK_MAX_ATTEMPTS", 16),
CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15),
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
} }
} }
// getEnvIntList 解析逗号分隔的整数列表(如 "15,15,30,180");空串返回 nil。
func getEnvIntList(key string, def []int) []int {
v := os.Getenv(key)
if v == "" {
return def
}
var out []int
for _, part := range strings.Split(v, ",") {
n, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || n < 1 {
continue
}
out = append(out, n)
}
if len(out) == 0 {
return def
}
return out
}
func getEnvBool(key string, def bool) bool { func getEnvBool(key string, def bool) bool {
v := os.Getenv(key) v := os.Getenv(key)
if v == "" { if v == "" {
+75 -10
View File
@@ -23,23 +23,76 @@ import (
"gorm.io/gorm" "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 方式管理回调,进程重启不会丢失待发送事件。 // CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
type CallbackService struct { type CallbackService struct {
db *gorm.DB db *gorm.DB
codec *SecretCodec codec *SecretCodec
httpClient *http.Client httpClient *http.Client
maxAttempts int
retrySchedule []time.Duration
} }
func NewCallbackService(db *gorm.DB, codec *SecretCodec) *CallbackService { 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{ return &CallbackService{
db: db, db: db,
codec: codec, codec: codec,
httpClient: &http.Client{Timeout: 10 * time.Second}, 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) { func (s *CallbackService) SetHTTPClient(client *http.Client) {
if client != nil { if client != nil {
s.httpClient = client s.httpClient = client
@@ -70,6 +123,16 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
if len(events) == 0 { if len(events) == 0 {
return nil, errors.New("至少订阅一个事件") 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) secret, err := randomToken("cb_", 32)
if err != nil { if err != nil {
return nil, err 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 { func (s *CallbackService) recordCallbackFailure(delivery model.CallbackDelivery, statusCode int, response string) error {
attempts := delivery.Attempts + 1 attempts := delivery.Attempts + 1
status := model.CallbackDeliveryPending status := model.CallbackDeliveryPending
nextAttempt := time.Now().Add(callbackRetryDelay(attempts)) nextAttempt := time.Now().Add(s.callbackRetryDelay(attempts))
if attempts >= maxCallbackAttempts { if attempts >= s.maxAttempts {
status = model.CallbackDeliveryFailed status = model.CallbackDeliveryFailed
nextAttempt = time.Now() nextAttempt = time.Now()
} }
@@ -321,15 +384,17 @@ func validateCallbackURL(rawURL string) error {
return nil return nil
} }
func callbackRetryDelay(attempts int) time.Duration { // callbackRetryDelay 返回第 attempts 次失败后的重试间隔(微信式固定序列)。
// 序列用尽后复用最后一个间隔。
func (s *CallbackService) callbackRetryDelay(attempts int) time.Duration {
if attempts < 1 { if attempts < 1 {
attempts = 1 attempts = 1
} }
delay := time.Second * time.Duration(1<<(attempts-1)) idx := attempts - 1
if delay > 15*time.Minute { if idx >= len(s.retrySchedule) {
return 15 * time.Minute idx = len(s.retrySchedule) - 1
} }
return delay return s.retrySchedule[idx]
} }
func truncateCallbackError(value string) string { func truncateCallbackError(value string) string {
+3
View File
@@ -52,6 +52,9 @@ services:
- DELIVERY_LINK_TTL_MINUTES=${DELIVERY_LINK_TTL_MINUTES:-120} - DELIVERY_LINK_TTL_MINUTES=${DELIVERY_LINK_TTL_MINUTES:-120}
- FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=${FULFILLMENT_PROCESSING_TIMEOUT_MINUTES:-30} - FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=${FULFILLMENT_PROCESSING_TIMEOUT_MINUTES:-30}
- FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=${FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS:-60} - FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=${FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS:-60}
- CALLBACK_MAX_ATTEMPTS=${CALLBACK_MAX_ATTEMPTS:-16}
- CALLBACK_PUSH_TIMEOUT_SECONDS=${CALLBACK_PUSH_TIMEOUT_SECONDS:-15}
- CALLBACK_RETRY_SCHEDULE=${CALLBACK_RETRY_SCHEDULE:-}
volumes: volumes:
- backend-data:/data - backend-data:/data
- ./logs:/app/logs - ./logs:/app/logs
+3 -1
View File
@@ -730,7 +730,9 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
const callbackContent = ( const callbackContent = (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}> <Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"> outbox </Typography.Text> <Typography.Text type="secondary">
outbox 退 16
</Typography.Text>
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => { {canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
callbackForm.resetFields() callbackForm.resetFields()
callbackForm.setFieldsValue({ events: ['order.shipping.updated'] }) callbackForm.setFieldsValue({ events: ['order.shipping.updated'] })