优化回调推送:参考微信/支付宝机制,限制每商户单一回调地址
- 回调重试改为微信式固定退避序列(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"
|
"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(
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -23,21 +23,74 @@ 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 {
|
||||||
return &CallbackService{
|
cfg := CallbackConfig{
|
||||||
db: db,
|
MaxAttempts: 16,
|
||||||
codec: codec,
|
PushTimeout: 15 * time.Second,
|
||||||
httpClient: &http.Client{Timeout: 10 * 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) {
|
func (s *CallbackService) SetHTTPClient(client *http.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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'] })
|
||||||
|
|||||||
Reference in New Issue
Block a user