回调优化为单地址 upsert 配置并支持重置密钥

- 回调订阅改为单记录 upsert:保存即覆盖(URL/事件/状态),自动停用其他订阅,无新增/删除
- 新增重置密钥:保存时传 rotate_secret=true 重新生成密钥并返回一次,解决密钥丢失无法找回
- 删除不再使用的 PATCH /callbacks/:id/status 路由及对应 handler/service 死代码
- 前端回调页改为内联表单(URL/事件/状态),新增重置密钥按钮(确认后展示新密钥一次)
- 补充单地址复用、disabled 不投递、密钥轮换测试
This commit is contained in:
yml2213
2026-08-03 11:23:46 +08:00
parent cf2ee96baa
commit 27a86f5311
9 changed files with 427 additions and 207 deletions
+13 -23
View File
@@ -313,30 +313,34 @@ func (h *MerchantHandler) DeleteAPIClient(c *gin.Context) {
}
func (h *MerchantHandler) ListCallbacks(c *gin.Context) {
list, err := h.callbackSvc.ListSubscriptions(middleware.GetMerchantID(c))
subscription, err := h.callbackSvc.GetSubscription(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, list)
response.OK(c, subscription)
}
type callbackReq struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Events string `json:"events" binding:"required"`
Name string `json:"name"`
URL string `json:"url" binding:"required"`
Events string `json:"events" binding:"required"`
Status string `json:"status"`
RotateSecret bool `json:"rotate_secret"`
}
func (h *MerchantHandler) CreateCallback(c *gin.Context) {
var req callbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:name、url、events 必填")
response.BadRequest(c, "参数错误:url、events 必填")
return
}
credential, err := h.callbackSvc.CreateSubscription(middleware.GetMerchantID(c), service.CreateCallbackInput{
Name: req.Name,
URL: req.URL,
Events: req.Events,
Name: req.Name,
URL: req.URL,
Events: req.Events,
Status: req.Status,
RotateSecret: req.RotateSecret,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
@@ -345,20 +349,6 @@ func (h *MerchantHandler) CreateCallback(c *gin.Context) {
response.OK(c, credential)
}
func (h *MerchantHandler) UpdateCallbackStatus(c *gin.Context) {
var req statusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.callbackSvc.UpdateSubscriptionStatus(middleware.GetMerchantID(c), uint(id), req.Status, middleware.GetUserID(c)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, nil)
}
func (h *MerchantHandler) ListMembers(c *gin.Context) {
members, err := h.merchantSvc.ListMembers(middleware.GetMerchantID(c))
if err != nil {
-1
View File
@@ -119,7 +119,6 @@ func Setup(h *Handlers) *gin.Engine {
merchant.DELETE("/api-clients/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.DeleteAPIClient)
merchant.GET("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks)
merchant.POST("/callbacks", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback)
merchant.PATCH("/callbacks/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureCallbacks), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateCallbackStatus)
merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers)
merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember)
}
+123 -99
View File
@@ -21,27 +21,28 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 回调推送策略(参考微信/支付宝通知机制):
// 首次推送失败后按固定序列退避重试,默认共 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 次
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 回调推送策略配置。
@@ -54,10 +55,10 @@ type CallbackConfig struct {
// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
type CallbackService struct {
db *gorm.DB
codec *SecretCodec
httpClient *http.Client
maxAttempts int
db *gorm.DB
codec *SecretCodec
httpClient *http.Client
maxAttempts int
retrySchedule []time.Duration
}
@@ -103,6 +104,9 @@ type CreateCallbackInput struct {
Name string
URL string
Events string
Status string
// RotateSecret 重置回调密钥:重新生成 secret(旧密钥立即失效),返回新 secret 仅此一次。
RotateSecret bool
}
type CallbackCredential struct {
@@ -114,7 +118,7 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
in.Name = strings.TrimSpace(in.Name)
in.URL = strings.TrimSpace(in.URL)
if in.Name == "" {
return nil, errors.New("回调名称不能为空")
in.Name = "默认回调"
}
if err := validateCallbackURL(in.URL); err != nil {
return nil, err
@@ -123,41 +127,69 @@ 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 in.Status == "" {
in.Status = model.CallbackStatusActive
}
if activeCount > 0 {
return nil, errors.New("每个商户仅支持一个回调地址,请先停用当前订阅再创建")
if in.Status != model.CallbackStatusActive && in.Status != model.CallbackStatusDisabled {
return nil, errors.New("无效的回调状态")
}
secret, err := randomToken("cb_", 32)
if err != nil {
return nil, err
}
ciphertext, err := s.codec.Encrypt(secret)
if err != nil {
return nil, err
}
subscription := &model.CallbackSubscription{
MerchantID: merchantID,
Name: in.Name,
URL: in.URL,
Events: strings.Join(events, ","),
SecretCiphertext: ciphertext,
Status: model.CallbackStatusActive,
}
err = s.db.Transaction(func(tx *gorm.DB) error {
subscription := &model.CallbackSubscription{}
secret := ""
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).
First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
if err := tx.Create(subscription).Error; err != nil {
err := tx.Where("merchant_id = ?", merchantID).Order("id DESC").First(subscription).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.create", "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL})
action := "callback_subscription.update"
rotate := in.RotateSecret
if errors.Is(err, gorm.ErrRecordNotFound) {
rotate = true // 新建时必然生成新密钥
action = "callback_subscription.create"
}
if rotate {
var tokenErr error
secret, tokenErr = randomToken("cb_", 32)
if tokenErr != nil {
return tokenErr
}
ciphertext, encryptErr := s.codec.Encrypt(secret)
if encryptErr != nil {
return encryptErr
}
if subscription.ID == 0 {
*subscription = model.CallbackSubscription{
MerchantID: merchantID,
SecretCiphertext: ciphertext,
}
} else {
subscription.SecretCiphertext = ciphertext
}
}
subscription.Name = in.Name
subscription.URL = in.URL
subscription.Events = strings.Join(events, ",")
subscription.Status = in.Status
if subscription.ID == 0 {
if err := tx.Create(subscription).Error; err != nil {
return err
}
} else if err := tx.Save(subscription).Error; err != nil {
return err
}
if err := tx.Model(&model.CallbackSubscription{}).
Where("merchant_id = ? AND id <> ? AND status = ?", merchantID, subscription.ID, model.CallbackStatusActive).
Update("status", model.CallbackStatusDisabled).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, action, "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL, "status": subscription.Status})
})
if err != nil {
return nil, err
@@ -165,65 +197,57 @@ func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackI
return &CallbackCredential{Subscription: subscription, Secret: secret}, nil
}
func (s *CallbackService) ListSubscriptions(merchantID uint) ([]model.CallbackSubscription, error) {
var subscriptions []model.CallbackSubscription
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&subscriptions).Error
return subscriptions, err
}
func (s *CallbackService) UpdateSubscriptionStatus(merchantID, id uint, status string, actorUserID uint) error {
if status != model.CallbackStatusActive && status != model.CallbackStatusDisabled {
return errors.New("无效的回调状态")
func (s *CallbackService) GetSubscription(merchantID uint) (*model.CallbackSubscription, error) {
var subscription model.CallbackSubscription
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").First(&subscription).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.CallbackSubscription{}).
Where("id = ? AND merchant_id = ?", id, merchantID).
Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("回调订阅不存在")
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.status.update", "callback_subscription", fmt.Sprint(id), map[string]string{"status": status})
})
if err != nil {
return nil, err
}
return &subscription, nil
}
// Enqueue 在调用方事务中写入回调 outbox,只有订单事务成功才会发送事件。
func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, data interface{}) error {
var subscriptions []model.CallbackSubscription
if err := tx.Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).Find(&subscriptions).Error; err != nil {
var subscription model.CallbackSubscription
err := tx.Where("merchant_id = ?", merchantID).
Order("id DESC").
First(&subscription).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
now := time.Now()
for _, subscription := range subscriptions {
if !subscribesTo(subscription.Events, event) {
continue
}
eventID := uuid.NewString()
payload, err := json.Marshal(map[string]interface{}{
"event_id": eventID,
"event": event,
"occurred_at": timeutil.FormatAPITime(now),
"data": data,
})
if err != nil {
return err
}
delivery := model.CallbackDelivery{
MerchantID: merchantID,
CallbackSubscriptionID: subscription.ID,
EventID: eventID,
Event: event,
Payload: string(payload),
Status: model.CallbackDeliveryPending,
NextAttemptAt: now,
}
if err := tx.Create(&delivery).Error; err != nil {
return err
}
if subscription.Status != model.CallbackStatusActive {
return nil
}
return nil
if !subscribesTo(subscription.Events, event) {
return nil
}
now := time.Now()
eventID := uuid.NewString()
payload, err := json.Marshal(map[string]interface{}{
"event_id": eventID,
"event": event,
"occurred_at": timeutil.FormatAPITime(now),
"data": data,
})
if err != nil {
return err
}
delivery := model.CallbackDelivery{
MerchantID: merchantID,
CallbackSubscriptionID: subscription.ID,
EventID: eventID,
Event: event,
Payload: string(payload),
Status: model.CallbackDeliveryPending,
NextAttemptAt: now,
}
return tx.Create(&delivery).Error
}
// DispatchDue 执行一批可发送的 outbox 记录。返回成功/失败尝试数,便于日志监控。
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"testing"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
func TestCallbackSubscriptionSaveReusesSingleConfigAndControlsDelivery(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "callback-single-config", 1000, 1, 100)
codec, err := NewSecretCodec("test-master-key")
if err != nil {
t.Fatalf("codec: %v", err)
}
svc := NewCallbackService(db, codec)
first, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/old-callback",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
}, 7)
if err != nil {
t.Fatalf("create callback: %v", err)
}
if first.Secret == "" {
t.Fatalf("new callback should return secret once")
}
second, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/new-callback",
Events: "order.shipping.updated",
Status: model.CallbackStatusDisabled,
}, 7)
if err != nil {
t.Fatalf("update callback: %v", err)
}
if second.Subscription.ID != first.Subscription.ID {
t.Fatalf("callback should reuse one row, first=%d second=%d", first.Subscription.ID, second.Subscription.ID)
}
if second.Secret != "" {
t.Fatalf("updated callback should not rotate or reveal secret")
}
var subscriptionCount int64
if err := db.Model(&model.CallbackSubscription{}).Where("merchant_id = ?", merchantID).Count(&subscriptionCount).Error; err != nil {
t.Fatalf("count subscriptions: %v", err)
}
if subscriptionCount != 1 {
t.Fatalf("expected one callback subscription, got %d", subscriptionCount)
}
if err := db.Transaction(func(tx *gorm.DB) error {
return svc.Enqueue(tx, merchantID, "order.shipping.updated", map[string]string{"source": "test"})
}); err != nil {
t.Fatalf("enqueue disabled callback: %v", err)
}
var deliveryCount int64
if err := db.Model(&model.CallbackDelivery{}).Where("merchant_id = ?", merchantID).Count(&deliveryCount).Error; err != nil {
t.Fatalf("count deliveries: %v", err)
}
if deliveryCount != 0 {
t.Fatalf("disabled callback should not enqueue delivery, got %d", deliveryCount)
}
third, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/new-callback",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
}, 7)
if err != nil {
t.Fatalf("enable callback: %v", err)
}
if third.Subscription.ID != first.Subscription.ID {
t.Fatalf("enabled callback should still reuse one row")
}
if err := db.Transaction(func(tx *gorm.DB) error {
return svc.Enqueue(tx, merchantID, "order.shipping.updated", map[string]string{"source": "test"})
}); err != nil {
t.Fatalf("enqueue active callback: %v", err)
}
if err := db.Model(&model.CallbackDelivery{}).Where("merchant_id = ?", merchantID).Count(&deliveryCount).Error; err != nil {
t.Fatalf("count deliveries after enable: %v", err)
}
if deliveryCount != 1 {
t.Fatalf("active callback should enqueue one delivery, got %d", deliveryCount)
}
}
func TestCallbackSubscriptionRotateSecret(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "callback-rotate-secret", 1000, 1, 100)
codec, err := NewSecretCodec("test-master-key")
if err != nil {
t.Fatalf("codec: %v", err)
}
svc := NewCallbackService(db, codec)
first, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/cb",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
}, 7)
if err != nil {
t.Fatalf("create callback: %v", err)
}
if first.Secret == "" {
t.Fatalf("new callback should return secret")
}
// 普通更新不轮换密钥
updated, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/cb",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
}, 7)
if err != nil {
t.Fatalf("update callback: %v", err)
}
if updated.Secret != "" {
t.Fatalf("plain update should not rotate secret")
}
// 显式重置密钥:返回新 secret,且与旧 secret 不同
rotated, err := svc.CreateSubscription(merchantID, CreateCallbackInput{
URL: "https://example.com/cb",
Events: "order.shipping.updated",
Status: model.CallbackStatusActive,
RotateSecret: true,
}, 7)
if err != nil {
t.Fatalf("rotate callback: %v", err)
}
if rotated.Secret == "" {
t.Fatalf("rotate should return new secret")
}
if rotated.Secret == first.Secret {
t.Fatalf("rotated secret should differ from old secret")
}
if rotated.Subscription.ID != first.Subscription.ID {
t.Fatalf("rotate should keep same subscription row")
}
}
+17 -12
View File
@@ -240,9 +240,9 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
in.OrderStatus = model.OrderStatusPaid
}
switch in.OrderStatus {
case model.OrderStatusPaid, model.OrderStatusShipFailed:
case model.OrderStatusPaid, model.OrderStatusShipFailed, model.OrderStatusCancelled:
default:
return nil, errors.New("测试订单仅支持已支付待发货发货失败状态")
return nil, errors.New("测试订单仅支持已支付待发货发货失败或已取消状态")
}
requestData := map[string]interface{}{
@@ -301,13 +301,18 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
if in.OrderStatus == model.OrderStatusShipFailed {
order.FailureReason = fallbackName(in.Note, "联调测试订单初始化为发货失败,可重新发货")
}
if in.OrderStatus == model.OrderStatusCancelled {
now := time.Now()
order.CancelledAt = &now
}
if err := tx.Create(order).Error; err != nil {
return err
}
canShip, _ := CanFulfill(order)
if err := writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "merchant_test_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{
"sku": in.SKU,
"order_status": in.OrderStatus,
"can_ship": true,
"can_ship": canShip,
}); err != nil {
return err
}
@@ -798,15 +803,15 @@ type DashboardStats struct {
UserCount int64 `json:"user_count"`
OrderCount int64 `json:"order_count"`
TodayOrderCount int64 `json:"today_order_count"`
TotalSales int64 `json:"total_sales"`
TodaySales int64 `json:"today_sales"`
TotalFees int64 `json:"total_fees"`
TodayFees int64 `json:"today_fees"`
PaidOrderCount int64 `json:"paid_order_count"`
DeliveringOrderCount int64 `json:"delivering_order_count"`
DeliveredOrderCount int64 `json:"delivered_order_count"`
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
CancelledOrderCount int64 `json:"cancelled_order_count"`
TotalSales int64 `json:"total_sales"`
TodaySales int64 `json:"today_sales"`
TotalFees int64 `json:"total_fees"`
TodayFees int64 `json:"today_fees"`
PaidOrderCount int64 `json:"paid_order_count"`
DeliveringOrderCount int64 `json:"delivering_order_count"`
DeliveredOrderCount int64 `json:"delivered_order_count"`
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
CancelledOrderCount int64 `json:"cancelled_order_count"`
WalletAvailableBalance int64 `json:"wallet_available_balance"`
WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
APIClientCount int64 `json:"api_client_count"`
+27 -2
View File
@@ -559,7 +559,7 @@ func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) {
t.Fatalf("create submitted order: %v", err)
}
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: merchantID,
MerchantID: merchantID,
APIClientID: 13,
OrderNo: submittedOrder.Order.OrderNo,
Status: model.OrderStatusDelivering,
@@ -582,7 +582,7 @@ func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) {
t.Fatalf("create interrupted order: %v", err)
}
if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: merchantID,
MerchantID: merchantID,
APIClientID: 13,
OrderNo: interruptedOrder.Order.OrderNo,
Status: model.OrderStatusDelivering,
@@ -805,6 +805,31 @@ func TestCreateTestOrderSupportsFailedFulfillableStatus(t *testing.T) {
}
}
func TestCreateTestOrderSupportsCancelledUnfulfillableStatus(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-cancelled", 0, -1, 100)
svc := NewFulfillmentService(db, nil)
order, err := svc.CreateTestOrder(CreateTestOrderInput{
MerchantID: merchantID,
SKU: product.SKU,
OrderStatus: model.OrderStatusCancelled,
})
if err != nil {
t.Fatalf("create cancelled test order: %v", err)
}
if order.OrderStatus != model.OrderStatusCancelled || order.CancelledAt == nil {
t.Fatalf("unexpected cancelled test order: %+v", order)
}
openOrder, err := svc.QueryOpenOrder(order.OrderNo)
if err != nil {
t.Fatalf("query open cancelled order: %v", err)
}
if openOrder.CanShip || openOrder.Status != "cancelled" || !strings.Contains(openOrder.CannotShipReason, "已取消") {
t.Fatalf("cancelled test order should be unfulfillable, got %+v", openOrder)
}
}
func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-source-notify", 1000, 2, 100)