优化回调推送:参考微信/支付宝机制,限制每商户单一回调地址
- 回调重试改为微信式固定退避序列(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:
@@ -20,6 +20,18 @@ import (
|
||||
"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() {
|
||||
cfg := config.Load()
|
||||
time.Local = timeutil.Location()
|
||||
@@ -54,7 +66,11 @@ func main() {
|
||||
tenantSvc := service.NewTenantService(db)
|
||||
authSvc := service.NewAuthService(db, jm, 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)
|
||||
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
||||
deliverySvc := service.NewDeliveryService(
|
||||
|
||||
@@ -40,6 +40,13 @@ type Config struct {
|
||||
FulfillmentProcessingTimeoutMinutes int
|
||||
// FulfillmentTimeoutScanIntervalSeconds 发货超时巡检间隔秒数。
|
||||
FulfillmentTimeoutScanIntervalSeconds int
|
||||
// CallbackMaxAttempts 回调推送总尝试次数(首次 + 重试),默认 16(微信式)。
|
||||
CallbackMaxAttempts int
|
||||
// CallbackPushTimeoutSeconds 单次回调推送 HTTP 超时秒数。
|
||||
CallbackPushTimeoutSeconds int
|
||||
// CallbackRetryScheduleSeconds 回调失败重试间隔序列(秒,逗号分隔,微信/支付宝式固定退避)。
|
||||
// 为空时使用默认微信式序列。
|
||||
CallbackRetryScheduleSeconds []int
|
||||
}
|
||||
|
||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||
@@ -66,9 +73,32 @@ func Load() *Config {
|
||||
DeliveryLinkTTLMinutes: getEnvInt("DELIVERY_LINK_TTL_MINUTES", 120),
|
||||
FulfillmentProcessingTimeoutMinutes: getEnvInt("FULFILLMENT_PROCESSING_TIMEOUT_MINUTES", 30),
|
||||
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 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -23,23 +23,76 @@ 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 {
|
||||
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: 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) {
|
||||
if client != nil {
|
||||
s.httpClient = 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 {
|
||||
|
||||
@@ -52,6 +52,9 @@ services:
|
||||
- DELIVERY_LINK_TTL_MINUTES=${DELIVERY_LINK_TTL_MINUTES:-120}
|
||||
- FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=${FULFILLMENT_PROCESSING_TIMEOUT_MINUTES:-30}
|
||||
- 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:
|
||||
- backend-data:/data
|
||||
- ./logs:/app/logs
|
||||
|
||||
@@ -730,7 +730,9 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
||||
const callbackContent = (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<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={() => {
|
||||
callbackForm.resetFields()
|
||||
callbackForm.setFieldsValue({ events: ['order.shipping.updated'] })
|
||||
|
||||
Reference in New Issue
Block a user