diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index 57e72dd..faa8bff 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -189,6 +189,28 @@ func (h *MerchantHandler) CreateTestOrder(c *gin.Context) { }) } +type merchantCancelOrderReq struct { + Reason string `json:"reason" binding:"required,max=512"` +} + +// CancelOrder 商户后台取消订单:全额退还积分并回补库存,取消后订单不可再发货。 +func (h *MerchantHandler) CancelOrder(c *gin.Context) { + var req merchantCancelOrderReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:取消原因必填且最长 512 字") + return + } + order, err := h.fulfillmentSvc.CancelOrderByUser(middleware.GetMerchantID(c), middleware.GetUserID(c), c.Param("order_no"), req.Reason) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{ + "order": order, + "refund_amount": order.Amount, + }) +} + func (h *MerchantHandler) GetDeliveryLink(c *gin.Context) { link, err := h.deliverySvc.GetOrCreateDeliveryLink(middleware.GetMerchantID(c), c.Param("order_no"), requestBaseURL(c)) if err != nil { @@ -199,7 +221,7 @@ func (h *MerchantHandler) GetDeliveryLink(c *gin.Context) { } func (h *MerchantHandler) RevokeDeliveryLink(c *gin.Context) { - if err := h.deliverySvc.RevokeDeliveryLink(middleware.GetMerchantID(c), c.Param("order_no")); err != nil { + if err := h.deliverySvc.RevokeDeliveryLink(middleware.GetMerchantID(c), middleware.GetUserID(c), c.Param("order_no")); err != nil { writeDeliveryError(c, err) return } @@ -207,7 +229,7 @@ func (h *MerchantHandler) RevokeDeliveryLink(c *gin.Context) { } func (h *MerchantHandler) RestoreDeliveryLink(c *gin.Context) { - link, err := h.deliverySvc.RestoreDeliveryLink(middleware.GetMerchantID(c), c.Param("order_no"), requestBaseURL(c)) + link, err := h.deliverySvc.RestoreDeliveryLink(middleware.GetMerchantID(c), middleware.GetUserID(c), c.Param("order_no"), requestBaseURL(c)) if err != nil { writeDeliveryError(c, err) return diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index ce378b2..0f77a15 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -121,7 +121,7 @@ func (h *OpenV1Handler) QueryOrder(c *gin.Context) { } type openCancelOrderReq struct { - Reason string `json:"reason"` + Reason string `json:"reason" binding:"max=512"` } func (h *OpenV1Handler) CancelOrder(c *gin.Context) { @@ -129,7 +129,7 @@ func (h *OpenV1Handler) CancelOrder(c *gin.Context) { var req openCancelOrderReq if err := c.ShouldBindJSON(&req); err != nil { openlog.Warn(c, "cancel_order bind_fail err=%v", err) - response.BadRequest(c, "参数错误") + response.BadRequest(c, "参数错误:reason 最长 512 字") return } orderNo := c.Param("order_no") diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3bf1e89..0dff39c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -163,6 +163,7 @@ func Setup(h *Handlers) *gin.Engine { merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.GetDeliveryLink) merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.RevokeDeliveryLink) merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.RestoreDeliveryLink) + merchant.POST("/orders/:order_no/cancel", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionOrdersManage), h.Merchant.CancelOrder) merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletView), h.Merchant.GetWallet) merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionWalletLedger), h.Merchant.ListWalletLedger) merchant.POST("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantPermissions(h.Tenant, model.PermissionRechargeManage), h.Recharge.CreateApplication) diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index e34b514..4e36ab3 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -617,6 +617,10 @@ func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requ if err != nil { return nil, err } + // 订单已取消后禁止获取发货链接(含复用未过期链接的路径),与 authorizeDeliveryLink 保持一致。 + if normalizeOrderStatus(order) == model.OrderStatusCancelled { + return nil, newDeliveryHTTPError(http.StatusBadRequest, "订单已取消,无法获取发货链接") + } now := time.Now().UTC() if order.DeliveryLinkRevokedAt != nil { return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废") @@ -643,7 +647,7 @@ func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requ return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil } -func (s *DeliveryService) RevokeDeliveryLink(merchantID uint, orderNo string) error { +func (s *DeliveryService) RevokeDeliveryLink(merchantID, actorUserID uint, orderNo string) error { orderNo = strings.TrimSpace(orderNo) if orderNo == "" { return errors.New("订单号不能为空") @@ -653,14 +657,21 @@ func (s *DeliveryService) RevokeDeliveryLink(merchantID uint, orderNo string) er return err } now := time.Now().UTC().Truncate(time.Second) - return s.fulfillment.db.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Updates(map[string]interface{}{ - "delivery_link_revoked_at": now, - }).Error + return s.fulfillment.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "delivery_link_revoked_at": now, + }).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.revoke", "fulfillment_order", order.OrderNo, map[string]interface{}{ + "revoked_at": timeutil.FormatAPITime(now), + }) + }) } -func (s *DeliveryService) RestoreDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { +func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { orderNo = strings.TrimSpace(orderNo) if orderNo == "" { return nil, errors.New("订单号不能为空") @@ -680,12 +691,19 @@ func (s *DeliveryService) RestoreDeliveryLink(merchantID uint, orderNo, requestB if order.DeliveryLinkExpiresAt != nil && expiresAt.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) { expiresAt = expiresAt.Add(time.Second) } - if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Updates(map[string]interface{}{ - "delivery_link_expires_at": expiresAt, - "delivery_link_revoked_at": nil, - }).Error; err != nil { + if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "delivery_link_expires_at": expiresAt, + "delivery_link_revoked_at": nil, + }).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, map[string]interface{}{ + "expires_at": timeutil.FormatAPITime(expiresAt), + }) + }); err != nil { return nil, err } return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil diff --git a/backend/internal/service/delivery_link.go b/backend/internal/service/delivery_link.go index 7353c1e..6d07be5 100644 --- a/backend/internal/service/delivery_link.go +++ b/backend/internal/service/delivery_link.go @@ -24,6 +24,10 @@ func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, a if order.DeliveryLinkRevokedAt != nil { return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废") } + // 订单已取消后链接立即失效,防止已退款订单仍通过链接查看或继续发货。 + if normalizeOrderStatus(order) == model.OrderStatusCancelled { + return newDeliveryHTTPError(http.StatusForbidden, "订单已取消,发货链接不可用") + } if order.DeliveryLinkExpiresAt == nil { return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成") } diff --git a/backend/internal/service/delivery_test.go b/backend/internal/service/delivery_test.go index f40d4c0..f4af162 100644 --- a/backend/internal/service/delivery_test.go +++ b/backend/internal/service/delivery_test.go @@ -41,7 +41,7 @@ func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) { t.Fatalf("authorize delivery link: %v", err) } - if err := deliverySvc.RevokeDeliveryLink(merchantID, created.Order.OrderNo); err != nil { + if err := deliverySvc.RevokeDeliveryLink(merchantID, 0, created.Order.OrderNo); err != nil { t.Fatalf("revoke delivery link: %v", err) } if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil { @@ -61,7 +61,7 @@ func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) { t.Fatalf("delivery_link_revoked_at should be stored") } - restored, err := deliverySvc.RestoreDeliveryLink(merchantID, created.Order.OrderNo, "https://shop.example") + restored, err := deliverySvc.RestoreDeliveryLink(merchantID, 0, created.Order.OrderNo, "https://shop.example") if err != nil { t.Fatalf("restore delivery link: %v", err) } @@ -78,6 +78,44 @@ func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) { } } +func TestDeliveryLinkRejectedAfterOrderCancelled(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "delivery-cancel", 5000, 5, 100) + fulfillmentSvc := NewFulfillmentService(db, nil) + deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 60) + + created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 1, + ClientOrderNo: "delivery-cancel-001", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + link, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo, "https://shop.example") + if err != nil { + t.Fatalf("get delivery link: %v", err) + } + // 未提交上游的已支付订单允许取消。 + if _, err := fulfillmentSvc.CancelOrderByUser(merchantID, 1, created.Order.OrderNo, "测试取消"); err != nil { + t.Fatalf("cancel order: %v", err) + } + // 取消后既有链接立即失效,无法再打开。 + if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil { + t.Fatalf("cancelled order link should be rejected") + } else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 { + t.Fatalf("expected 403 for cancelled order link, got %v", err) + } + // 取消后也不能再生成新链接或恢复链接。 + if _, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo, "https://shop.example"); err == nil { + t.Fatalf("cancelled order should not generate a new link") + } + if _, err := deliverySvc.RestoreDeliveryLink(merchantID, 1, created.Order.OrderNo, "https://shop.example"); err == nil { + t.Fatalf("cancelled order should not restore a link") + } +} + func TestDeliveryGoodIDIncludesMedalAndCoinProducts(t *testing.T) { cases := map[string]string{ "honor_medal_x2": "682ef39ca8f40c4234c59f45", diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 0dac94b..35e105f 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -6,6 +6,7 @@ import ( "math" "strings" "time" + "unicode/utf8" "affiliate_dash/internal/model" @@ -446,12 +447,52 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode return &out, nil } +// CancelOrderInput 取消订单的公共输入;开放 API 与商户后台共用同一取消事务。 +type CancelOrderInput struct { + MerchantID uint + APIClientID uint + ActorUserID uint + OrderNo string + Reason string + Action string +} + +// CancelOrder 商户开放接口取消订单(审计记录 API 客户端)。 func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { + return s.cancelOrder(CancelOrderInput{ + MerchantID: merchantID, + APIClientID: apiClientID, + OrderNo: orderNo, + Reason: reason, + Action: "open_order.cancel", + }) +} + +// CancelOrderByUser 商户后台取消订单(审计记录操作人)。 +func (s *FulfillmentService) CancelOrderByUser(merchantID, actorUserID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { + return s.cancelOrder(CancelOrderInput{ + MerchantID: merchantID, + ActorUserID: actorUserID, + OrderNo: orderNo, + Reason: reason, + Action: "merchant_order.cancel", + }) +} + +func (s *FulfillmentService) cancelOrder(in CancelOrderInput) (*model.FulfillmentOrder, error) { + in.OrderNo = strings.TrimSpace(in.OrderNo) + in.Reason = strings.TrimSpace(in.Reason) + if in.MerchantID == 0 || in.OrderNo == "" { + return nil, errors.New("无效的商户或订单号") + } + if utf8.RuneCountInString(in.Reason) > 512 { + return nil, errors.New("取消原因最长 512 字") + } var out model.FulfillmentOrder err := s.db.Transaction(func(tx *gorm.DB) error { var order model.FulfillmentOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). + Where("merchant_id = ? AND order_no = ?", in.MerchantID, in.OrderNo). First(&order).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("订单不存在") @@ -465,15 +506,20 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, if err := canCancelOrder(&order); err != nil { return err } + // 已提交上游正式订单的失败订单禁止取消:退款后上游仍可能继续发货,造成双重损失。 + // 与发货重试的防重复逻辑(deliverySubmittedUpstream)保持一致。 + if normalizeOrderStatus(&order) == model.OrderStatusShipFailed && deliverySubmittedUpstream(&order) { + return errors.New("订单已提交上游发货,为避免重复发货无法取消,请先在上游确认订单状态") + } now := time.Now() updates := map[string]interface{}{"order_status": model.OrderStatusCancelled} - updates["failure_reason"] = reason + updates["failure_reason"] = in.Reason updates["cancelled_at"] = now if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } var wallet model.WalletAccount - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { return err } newBalance := wallet.AvailableBalance + order.Amount @@ -482,7 +528,7 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, } idempotencyKey := "cancel:" + order.OrderNo if err := tx.Create(&model.WalletLedgerEntry{ - MerchantID: merchantID, + MerchantID: in.MerchantID, WalletAccountID: wallet.ID, EntryNo: "WL" + uuid.NewString(), Type: model.WalletLedgerRefund, @@ -507,11 +553,13 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, if err := tx.First(&out, order.ID).Error; err != nil { return err } - if err := writeAudit(tx, &merchantID, nil, &apiClientID, "open_order.cancel", "fulfillment_order", order.OrderNo, nil); err != nil { + if err := writeAudit(tx, &in.MerchantID, optionalUint(in.ActorUserID), optionalUint(in.APIClientID), in.Action, "fulfillment_order", order.OrderNo, map[string]interface{}{ + "reason": in.Reason, + }); err != nil { return err } if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, merchantID, "order.cancelled", orderCallbackData(&out)); err != nil { + if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.cancelled", orderCallbackData(&out)); err != nil { return err } } diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index c6439d2..b8f46bb 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -404,6 +404,103 @@ func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) { } } +func TestFulfillmentCancelByUserRefundsAndAuditsActor(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-bu", 1000, 2, 300) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 12, + ClientOrderNo: "client-cancel-byuser", + SKU: product.SKU, + Quantity: 1, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + cancelled, err := svc.CancelOrderByUser(merchantID, 99, created.Order.OrderNo, "商户后台取消") + if err != nil { + t.Fatalf("cancel by user: %v", err) + } + if cancelled.OrderStatus != model.OrderStatusCancelled { + t.Fatalf("unexpected cancelled order: %+v", cancelled) + } + var wallet model.WalletAccount + _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error + if wallet.AvailableBalance != 1000 { + t.Fatalf("wallet should refund full amount, got %d", wallet.AvailableBalance) + } + var audit model.AuditLog + if err := db.Where("action = ? AND entity_id = ?", "merchant_order.cancel", created.Order.OrderNo).First(&audit).Error; err != nil { + t.Fatalf("cancel audit missing: %v", err) + } + if audit.ActorUserID == nil || *audit.ActorUserID != 99 { + t.Fatalf("cancel audit should record actor user 99, got %v", audit.ActorUserID) + } + if audit.APIClientID != nil { + t.Fatalf("cancel audit should not record api client for backend path, got %v", *audit.APIClientID) + } +} + +func TestFulfillmentCancelRejectedWhenSubmittedUpstream(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-bd", 1000, 2, 300) + svc := NewFulfillmentService(db, nil) + create := func(clientOrderNo string) *model.FulfillmentOrder { + t.Helper() + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 12, + ClientOrderNo: clientOrderNo, + SKU: product.SKU, + Quantity: 1, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + return created.Order + } + + // 已提交上游(provider_order_no 非空)的失败订单禁止取消,避免退款后上游仍发货。 + submitted := create("client-cancel-submitted") + if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", submitted.ID).Updates(map[string]interface{}{ + "order_status": model.OrderStatusShipFailed, + "provider_order_no": "PROV-UPSTREAM-001", + "failure_reason": "上游角色校验失败", + }).Error; err != nil { + t.Fatalf("seed submitted failed order: %v", err) + } + if _, err := svc.CancelOrder(merchantID, 12, submitted.OrderNo, "想取消"); err == nil { + t.Fatalf("cancel must be rejected when order already submitted upstream") + } + var walletAfterReject model.WalletAccount + _ = db.Where("merchant_id = ?", merchantID).First(&walletAfterReject).Error + if walletAfterReject.AvailableBalance != 700 { + t.Fatalf("wallet must not be refunded after rejected cancel, got %d", walletAfterReject.AvailableBalance) + } + + // 未提交上游的失败订单(提交中断)可以正常取消退款。 + interrupted := create("client-cancel-interrupted") + if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", interrupted.ID).Updates(map[string]interface{}{ + "order_status": model.OrderStatusShipFailed, + "failure_reason": "发货提交中断", + }).Error; err != nil { + t.Fatalf("seed interrupted failed order: %v", err) + } + cancelled, err := svc.CancelOrder(merchantID, 12, interrupted.OrderNo, "确认中断,取消退款") + if err != nil { + t.Fatalf("cancel interrupted failed order: %v", err) + } + if cancelled.OrderStatus != model.OrderStatusCancelled { + t.Fatalf("expected cancelled, got %s", cancelled.OrderStatus) + } + var walletFinal model.WalletAccount + _ = db.Where("merchant_id = ?", merchantID).First(&walletFinal).Error + if walletFinal.AvailableBalance != 700 { + t.Fatalf("wallet should refund the interrupted order (1000-300-300+300), got %d", walletFinal.AvailableBalance) + } +} + func TestOrderStatusTransitions(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-c", 1000, -1, 100) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d073ce0..68869ce 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -85,6 +85,8 @@ export const merchantApi = { game_account?: string note?: string }) => request.post('/merchant/orders/manual', data).then((r) => r.data.data as CreateManualOrderResult), + cancelOrder: (orderNo: string, reason: string) => + request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/cancel`, { reason }).then((r) => r.data.data as { order: FulfillmentOrder; refund_amount: number }), getDeliveryLink: (orderNo: string) => request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult), revokeDeliveryLink: (orderNo: string) => diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 4963df4..505f281 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -131,7 +131,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer message.success('发货链接已作废') patchDeliveryLinkState(orderNo, { delivery_link_revoked_at: revokedAt }) }) - .catch((e) => message.error(e instanceof Error ? e.message : '作废失败')) + .catch((e) => message.error(e instanceof Error ? e.message : '链接作废失败')) }, [patchDeliveryLinkState]) const restoreDeliveryLink = useCallback((orderNo: string) => { @@ -355,6 +355,12 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer loadOrders(1, orders.size, orderSource, filters).catch((e) => message.error(e instanceof Error ? e.message : '订单加载失败')) } + const cancelOrder = useCallback(async (orderNo: string, reason: string) => { + const result = await merchantApi.cancelOrder(orderNo, reason) + message.success(`订单已取消,已退还 ${Number(result.refund_amount || 0)} 积分`) + await loadOrders() + }, [loadOrders]) + const submitAPIClient = async () => { const values = await apiClientForm.validateFields() try { @@ -510,7 +516,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer ) }, { key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: ( - + ) }, { key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: ( diff --git a/frontend/src/pages/merchantCenterTabs.tsx b/frontend/src/pages/merchantCenterTabs.tsx index 0a31672..4e67445 100644 --- a/frontend/src/pages/merchantCenterTabs.tsx +++ b/frontend/src/pages/merchantCenterTabs.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Button, Card, Col, Form, Input, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd' +import { Alert, Button, Card, Col, Form, Input, Modal, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd' import type { FormInstance } from 'antd' import { ClockCircleOutlined, @@ -183,6 +183,7 @@ export function OrdersTab({ onOpenLink, onRevokeLink, onRestoreLink, + onCancelOrder, }: { loading: boolean orders: PageResult @@ -197,8 +198,12 @@ export function OrdersTab({ onOpenLink: (orderNo: string) => void onRevokeLink: (orderNo: string) => void onRestoreLink: (orderNo: string) => void + onCancelOrder: (orderNo: string, reason: string) => Promise | void }) { const [keyword, setKeyword] = useState(filters.keyword) + const [cancelling, setCancelling] = useState(null) + const [cancelReason, setCancelReason] = useState('') + const [cancelSubmitting, setCancelSubmitting] = useState(false) useEffect(() => { setKeyword(filters.keyword) @@ -206,6 +211,22 @@ export function OrdersTab({ const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() }) + const submitCancel = async () => { + if (!cancelling) return + if (!cancelReason.trim()) { + message.warning('请填写取消原因') + return + } + setCancelSubmitting(true) + try { + await onCancelOrder(cancelling.order_no, cancelReason.trim()) + setCancelling(null) + setCancelReason('') + } finally { + setCancelSubmitting(false) + } + } + const orderColumns: ColumnsType = [ { title: '平台订单号', @@ -237,16 +258,55 @@ export function OrdersTab({ { title: '发货链接', key: 'delivery_link', - width: 150, - render: (_, record) => record.delivery_link_revoked_at ? ( - - ) : ( - - - - - - ), + width: 160, + render: (_, record) => { + if (record.order_status === 'cancelled') { + return 订单已取消 + } + if (record.delivery_link_revoked_at) { + return + } + return ( + + + + + + ) + }, + }, + { + title: '操作', + key: 'action', + width: 110, + fixed: 'right', + render: (_, record) => { + if (!canManage) return '-' + if (record.order_status === 'paid' || record.order_status === 'ship_failed') { + return ( + + ) + } + return '-' + }, }, ] @@ -300,9 +360,38 @@ export function OrdersTab({ columns={orderColumns} dataSource={orders.list} tableLayout="fixed" - scroll={{ x: 1660 }} + scroll={{ x: 1780 }} pagination={pageConfig(orders, onLoadOrders)} /> + { + setCancelling(null) + setCancelReason('') + }} + okText="确认取消并退款" + okButtonProps={{ danger: true }} + destroyOnClose + > + + setCancelReason(e.target.value)} + placeholder="请填写取消原因(必填)" + /> + ) }