diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index 648bd10..5ee2659 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -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 { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fb927c6..3887143 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } diff --git a/backend/internal/service/callback.go b/backend/internal/service/callback.go index e6f63b8..b117320 100644 --- a/backend/internal/service/callback.go +++ b/backend/internal/service/callback.go @@ -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 记录。返回成功/失败尝试数,便于日志监控。 diff --git a/backend/internal/service/callback_test.go b/backend/internal/service/callback_test.go new file mode 100644 index 0000000..0dbd8ed --- /dev/null +++ b/backend/internal/service/callback_test.go @@ -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") + } +} diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 6788105..94c7d4d 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -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"` diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index f8fe594..bc38b05 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -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) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index fc3b856..f8c3bba 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -67,7 +67,7 @@ export const merchantApi = { sku: string buyer_reference?: string note?: string - order_status?: 'paid' | 'ship_failed' + order_status?: 'paid' | 'ship_failed' | 'cancelled' }) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult), getDeliveryLink: (orderNo: string) => request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult), @@ -94,11 +94,9 @@ export const merchantApi = { deleteApiClient: (id: number) => request.delete(`/merchant/api-clients/${id}`).then((r) => r.data.data), callbacks: () => - request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription[]), - createCallback: (data: { name: string; url: string; events: string }) => + request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription | null), + saveCallback: (data: { url: string; events: string; status: CallbackSubscription['status']; rotate_secret?: boolean }) => request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential), - updateCallbackStatus: (id: number, status: CallbackSubscription['status']) => - request.patch(`/merchant/callbacks/${id}/status`, { status }).then((r) => r.data.data), members: () => request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]), addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) => diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index a6c2c9b..fe701c4 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -77,6 +77,7 @@ const eventOptions = [ const testOrderStatusOptions = [ { value: 'paid', label: '已支付,可发货' }, { value: 'ship_failed', label: '发货失败,可重试' }, + { value: 'cancelled', label: '已取消,不可发货' }, ] type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members' @@ -97,7 +98,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [ledger, setLedger] = useState>({ list: [], total: 0, page: 1, size: 10 }) const [wallet, setWallet] = useState(null) const [apiClients, setApiClients] = useState([]) - const [callbacks, setCallbacks] = useState([]) + const [callback, setCallback] = useState(null) const [members, setMembers] = useState([]) const [loading, setLoading] = useState(false) const [productOpen, setProductOpen] = useState(false) @@ -105,7 +106,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [walletOpen, setWalletOpen] = useState(false) const [apiClientOpen, setApiClientOpen] = useState(false) const [apiCredential, setApiCredential] = useState(null) - const [callbackOpen, setCallbackOpen] = useState(false) const [callbackCredential, setCallbackCredential] = useState(null) const [memberOpen, setMemberOpen] = useState(false) const [testOrderOpen, setTestOrderOpen] = useState(false) @@ -214,12 +214,18 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const loadCallbacks = useCallback(async (role = merchantRole) => { if (role !== 'owner' && role !== 'operator') { - setCallbacks([]) + setCallback(null) + callbackForm.resetFields() return } const callbackData = await merchantApi.callbacks() - setCallbacks(callbackData || []) - }, [merchantRole]) + setCallback(callbackData || null) + callbackForm.setFieldsValue(callbackData ? { + url: callbackData.url, + events: eventsToValue(callbackData.events), + status: callbackData.status, + } : defaultCallbackFormValues()) + }, [callbackForm, merchantRole]) const loadMembers = useCallback(async (role = merchantRole) => { if (role !== 'owner' && role !== 'operator') { @@ -414,17 +420,37 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const submitCallback = async () => { const values = await callbackForm.validateFields() try { - const credential = await merchantApi.createCallback({ - name: values.name, + const credential = await merchantApi.saveCallback({ url: values.url, events: (values.events || []).join(','), + status: values.status, }) - setCallbackCredential(credential) - setCallbackOpen(false) - message.success('回调已创建') - loadCallbacks() + setCallback(credential.subscription) + if (credential.secret) { + setCallbackCredential(credential) + } + message.success('回调配置已保存') } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') + message.error(e instanceof Error ? e.message : '保存失败') + } + } + + const rotateCallbackSecret = async () => { + const values = await callbackForm.validateFields() + try { + const credential = await merchantApi.saveCallback({ + url: values.url, + events: (values.events || []).join(','), + status: values.status, + rotate_secret: true, + }) + setCallback(credential.subscription) + if (credential.secret) { + setCallbackCredential(credential) + } + message.success('回调密钥已重置,旧密钥立即失效') + } catch (e) { + message.error(e instanceof Error ? e.message : '重置失败') } } @@ -464,16 +490,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer } } - const toggleCallback = async (record: CallbackSubscription) => { - try { - await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active') - message.success('状态已更新') - loadCallbacks() - } catch (e) { - message.error(e instanceof Error ? e.message : '更新失败') - } - } - const productColumns: ColumnsType = [ { title: 'SKU', dataIndex: 'sku', width: 200, ellipsis: true, render: (v) => {v} }, { title: '名称', dataIndex: 'display_name', width: 200, ellipsis: true, render: (_, r) => r.display_name || r.product?.name || '-' }, @@ -579,24 +595,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer }, ] - const callbackColumns: ColumnsType = [ - { title: '名称', dataIndex: 'name', width: 180, ellipsis: true }, - { title: 'URL', dataIndex: 'url', ellipsis: true }, - { title: '事件', dataIndex: 'events', width: 260, ellipsis: true }, - { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, - { title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, - { - title: '操作', - key: 'action', - width: 90, - render: (_, record) => canManage ? ( - - ) : '-', - }, - ] - const memberColumns: ColumnsType = [ { title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` }, { title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' }, @@ -729,17 +727,47 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const callbackContent = ( - - - 回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户仅支持一个回调地址,变更地址请先停用当前订阅。 - - {canManage && } - - + + 回调事件通过 outbox 持久化推送,失败按固定间隔退避重试(最多 16 次)后标记失败。每个商户只保留一份回调配置。 + + +
+ + + + + + + + + {callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'} + + + {canManage && ( + + + + )} + {canManage && } + + + +
) @@ -904,6 +932,11 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer {testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'} + {!testOrderResult.can_ship && ( + + {testOrderResult.cannot_ship_reason || '-'} + + )} {testOrderResult.order.delivery_link_revoked_at ? 已作废 : formatDateTime(testOrderResult.order.delivery_link_expires_at)} @@ -953,20 +986,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer {apiCredential && } - setCallbackOpen(false)} destroyOnClose> -
- - - - - - - -