From c2d822851269887136e989cf7630bb1214ca3b6d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 14 Sep 2026 20:13:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(delivery):=20=E7=A1=AC=E5=8C=96=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E8=B4=A7=E5=8F=AF=E9=9D=A0=E6=80=A7=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8F=8C=E5=8F=91=E4=B8=8E=E6=97=A0=E9=99=90?= =?UTF-8?q?=20Webhook=20=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复 ship_failed 可重试 UI;DLC 断点续传 queue;乐玩未绑定禁止进 delivering;Webhook 原子 nonce 与业务拒绝回 200。 Co-authored-by: Cursor --- backend/internal/handler/lewan_webhook.go | 60 +++++++++++++++---- backend/internal/service/delivery.go | 22 ++++++- backend/internal/service/delivery_test.go | 54 +++++++++++++++++ backend/internal/service/fulfillment.go | 4 +- backend/internal/service/fulfillment_data.go | 35 +++++++++++ .../service/fulfillment_shipnotify.go | 17 +++++- backend/internal/service/fulfillment_test.go | 30 ++++++++++ backend/internal/service/provider_dlc.go | 22 +++++-- backend/internal/service/provider_lewan.go | 4 ++ frontend/src/pages/Delivery.tsx | 16 +++-- 10 files changed, 235 insertions(+), 29 deletions(-) diff --git a/backend/internal/handler/lewan_webhook.go b/backend/internal/handler/lewan_webhook.go index d311a54..06a42f6 100644 --- a/backend/internal/handler/lewan_webhook.go +++ b/backend/internal/handler/lewan_webhook.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "io" "net/http" "strings" @@ -14,6 +15,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // LewanWebhookHandler 接收乐玩开放平台的 Webhook 事件推送 @@ -108,49 +110,81 @@ func (h *LewanWebhookHandler) Handle(c *gin.Context) { } } - // 幂等:已处理过的 eventId 直接 200 - if h.db != nil && payload.EventID != "" { - var existing int64 - if err := h.db.Model(&model.SourceAPINonce{}). - Where("api_key = ? AND nonce = ?", "lewan_webhook", payload.EventID). - Count(&existing).Error; err == nil && existing > 0 { + // 幂等:原子占位 nonce,避免并发双处理;占位失败说明已处理/处理中 + if claimed, duplicate := h.claimWebhookNonce(payload.EventID); !claimed { + if duplicate { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "duplicate event, ignored"}) return } + c.JSON(http.StatusInternalServerError, gin.H{"error": "record webhook nonce failed"}) + return } // 特殊测试事件:验签通过后直接确认 if payload.Type == "WEBHOOK_TEST" { - h.recordWebhookNonce(payload.EventID) c.JSON(http.StatusOK, gin.H{"code": 0, "message": "test event received"}) return } orderNo := payload.Data.CallerOrderID if orderNo == "" { + // 无订单号的事件无法落地,记 nonce 防重放后确认 c.JSON(http.StatusOK, gin.H{"code": 0, "message": "missing callerOrderId, ignored"}) return } if err := h.dispatchShipNotify(payload, string(rawBody)); err != nil { - // 返回 5xx 让上游重试;尚未写入 nonce + if isPermanentWebhookRejection(err) { + // 业务永久拒绝:回 200 避免上游无限重试;nonce 已占位 + c.JSON(http.StatusOK, gin.H{"code": 0, "message": err.Error()}) + return + } + // 可重试错误:释放 nonce,回 5xx 让上游重试 + h.releaseWebhookNonce(payload.EventID) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - h.recordWebhookNonce(payload.EventID) response.OK(c, gin.H{"received": true}) } -func (h *LewanWebhookHandler) recordWebhookNonce(eventID string) { +func (h *LewanWebhookHandler) claimWebhookNonce(eventID string) (claimed bool, duplicate bool) { if h.db == nil || eventID == "" { - return + return true, false } - _ = h.db.Create(&model.SourceAPINonce{ + created := h.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SourceAPINonce{ APIKey: "lewan_webhook", Nonce: eventID, ExpiresAt: time.Now().Add(7 * 24 * time.Hour), - }).Error + }) + if created.Error != nil { + return false, false + } + if created.RowsAffected == 0 { + return false, true + } + return true, false +} + +func (h *LewanWebhookHandler) releaseWebhookNonce(eventID string) { + if h.db == nil || eventID == "" { + return + } + _ = h.db.Where("api_key = ? AND nonce = ?", "lewan_webhook", eventID). + Delete(&model.SourceAPINonce{}).Error +} + +func isPermanentWebhookRejection(err error) bool { + if err == nil { + return false + } + if errors.Is(err, service.ErrShipNotifyRejected) { + return true + } + msg := err.Error() + return strings.Contains(msg, "订单已取消") || + strings.Contains(msg, "订单已交付,拒绝失败") || + strings.Contains(msg, "当前状态不允许") } func (h *LewanWebhookHandler) dispatchShipNotify(payload WebhookPayload, rawBody string) error { diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index d058ae3..ba04f57 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -548,8 +548,21 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien activeProvider := providers[0] submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID) + if submitRes != nil && len(submitRes.PatchResultData) > 0 { + stage := deliveryStageCreateOrder + if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { + stage = v + } + _ = s.recordDeliverySubmissionStage(claimed, stage, submitRes.PatchResultData) + } if err != nil { - s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, err.Error()) + failStage := deliveryStageCreateOrder + if submitRes != nil { + if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { + failStage = v + } + } + s.markDeliverySubmissionFailed(claimed, apiClientID, failStage, err.Error()) return nil, err } @@ -563,6 +576,8 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien ResultData: resultData, }) if err != nil { + // 上游已有身份时先固化 result_data,避免重试双发 + _ = s.recordDeliverySubmissionStage(claimed, deliveryStageSubmitted, submitRes.PatchResultData) return nil, err } return &DeliverySubmitResult{ @@ -599,7 +614,10 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, } now := time.Now() stage := resultDataString(order.ResultData, "provider_order_stage") - if deliverySubmissionInProgress(stage) && !deliverySubmissionStale(&order, now) { + // ship_failed 可立即重试;仅对仍处于 paid 等提交中态拦截并发双发 + if orderStatus != model.OrderStatusShipFailed && + deliverySubmissionInProgress(stage) && + !deliverySubmissionStale(&order, now) { updated = order return nil } diff --git a/backend/internal/service/delivery_test.go b/backend/internal/service/delivery_test.go index 8619ad3..bccaf82 100644 --- a/backend/internal/service/delivery_test.go +++ b/backend/internal/service/delivery_test.go @@ -451,6 +451,60 @@ func TestDeliveryMerchantApiBindAndSubmit(t *testing.T) { } } +func TestDeliveryClaimAllowsShipFailedWithQueueStage(t *testing.T) { + recentStartedAt := time.Now().Add(-time.Minute) + merchantID, deliverySvc, order := createDeliveryOrderWithSubmitStage(t, "delivery-claim-failed-queue", deliveryStageQueueCreated, recentStartedAt, 1) + if err := deliverySvc.fulfillment.db.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "order_status": model.OrderStatusShipFailed, + "failure_reason": "补充发货账号失败", + "result_data": mergeResultData(order.ResultData, map[string]interface{}{ + "queue_order_id": "queue-resume-1", + }), + }).Error; err != nil { + t.Fatalf("seed ship_failed: %v", err) + } + + claimed, claimedNow, err := deliverySvc.claimDeliverySubmission(merchantID, 88, order.OrderNo) + if err != nil { + t.Fatalf("claim ship_failed with queue stage: %v", err) + } + if !claimedNow { + t.Fatalf("ship_failed should allow immediate reclaim even with queue_created stage") + } + if got := resultDataString(claimed.ResultData, "queue_order_id"); got != "queue-resume-1" { + t.Fatalf("reclaim should preserve queue_order_id, got %q", got) + } + if got := resultDataNumber(claimed.ResultData, "ship_attempts"); got != 2 { + t.Fatalf("reclaim should count a new attempt, got %d", got) + } +} + +func TestEncodeFulfillmentResultDataAvoidsDoubleMarshal(t *testing.T) { + rawJSON := `{"provider":"dlc","queue_order_id":"q1"}` + encoded, err := encodeFulfillmentResultData(rawJSON) + if err != nil { + t.Fatalf("encode string json: %v", err) + } + if encoded != rawJSON { + t.Fatalf("json string should pass through, got %q", encoded) + } + + encodedMap, err := encodeFulfillmentResultData(map[string]interface{}{"provider": "dlc"}) + if err != nil { + t.Fatalf("encode map: %v", err) + } + if !json.Valid([]byte(encodedMap)) || !strings.Contains(encodedMap, `"provider":"dlc"`) { + t.Fatalf("map should marshal once, got %q", encodedMap) + } + + // 若对已是 JSON 字符串再 Marshal 会变成带转义的字符串;这里确保不会 + if strings.HasPrefix(encoded, "\"") { + t.Fatalf("should not double-encode json object string: %q", encoded) + } +} + func TestDeliveryClaimBlocksRecentInProgressStage(t *testing.T) { recentStartedAt := time.Now().Add(-time.Minute) merchantID, deliverySvc, order := createDeliveryOrderWithSubmitStage(t, "delivery-claim-recent", deliveryStageClaimed, recentStartedAt, 1) diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index aa85ad4..22e4a76 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -399,11 +399,11 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode var resultData string hasResultData := in.ResultData != nil if in.ResultData != nil { - raw, err := json.Marshal(in.ResultData) + encoded, err := encodeFulfillmentResultData(in.ResultData) if err != nil { return nil, errors.New("发货结果无法序列化") } - resultData = string(raw) + resultData = encoded } var out model.FulfillmentOrder err := s.db.Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/service/fulfillment_data.go b/backend/internal/service/fulfillment_data.go index 2eb9eb7..eaf383c 100644 --- a/backend/internal/service/fulfillment_data.go +++ b/backend/internal/service/fulfillment_data.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "encoding/json" "strconv" "strings" @@ -62,6 +63,40 @@ func mergeResultData(existing string, patch map[string]interface{}) string { return string(raw) } +// encodeFulfillmentResultData 写入 result_data:已是 JSON 对象/数组字符串时不再二次 Marshal,避免双重编码。 +func encodeFulfillmentResultData(v interface{}) (string, error) { + switch t := v.(type) { + case nil: + return "", nil + case string: + trimmed := strings.TrimSpace(t) + if trimmed == "" { + return "", nil + } + if json.Valid([]byte(trimmed)) && (strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) { + return trimmed, nil + } + raw, err := json.Marshal(t) + return string(raw), err + case []byte: + trimmed := bytes.TrimSpace(t) + if len(trimmed) == 0 { + return "", nil + } + if json.Valid(trimmed) && (trimmed[0] == '{' || trimmed[0] == '[') { + return string(trimmed), nil + } + raw, err := json.Marshal(t) + return string(raw), err + default: + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(raw), nil + } +} + func resultDataMap(raw string) map[string]interface{} { m := map[string]interface{}{} if raw == "" { diff --git a/backend/internal/service/fulfillment_shipnotify.go b/backend/internal/service/fulfillment_shipnotify.go index 3dd0131..0f84832 100644 --- a/backend/internal/service/fulfillment_shipnotify.go +++ b/backend/internal/service/fulfillment_shipnotify.go @@ -33,6 +33,17 @@ type ShipNotifyResult struct { Message string `json:"message"` } +// ErrShipNotifyRejected 表示业务永久拒绝(如已取消/已交付拒失败),Webhook 应回 200 避免无限重试。 +var ErrShipNotifyRejected = errors.New("ship notify rejected") + +// wrapShipNotifyRejected 将永久拒绝包装为可识别错误。 +func wrapShipNotifyRejected(err error) error { + if err == nil { + return ErrShipNotifyRejected + } + return errors.Join(ErrShipNotifyRejected, err) +} + // HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。 func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { in.ShipStatus = strings.TrimSpace(in.ShipStatus) @@ -97,7 +108,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil { return err } - rejectionErr = errors.New("订单已取消,无法更新发货状态") + rejectionErr = wrapShipNotifyRejected(errors.New("订单已取消,无法更新发货状态")) return nil } @@ -112,7 +123,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { return err } - rejectionErr = err + rejectionErr = wrapShipNotifyRejected(err) return nil } updates["delivered_at"] = shippedAt @@ -130,7 +141,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { return err } - rejectionErr = err + rejectionErr = wrapShipNotifyRejected(err) return nil } updates["failure_reason"] = in.FailReason diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index b8f46bb..6f6ffeb 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "errors" "strings" "testing" "time" @@ -1155,3 +1156,32 @@ func TestBuildShipNotifyResultDataSuccessRemovesFailureReason(t *testing.T) { t.Fatalf("success result data should keep ship_status, got %s", result) } } + +func TestHandleShipNotifyRejectedOnCancelledReturnsTypedError(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-notify-cancel", 1000, 2, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 41, + ClientOrderNo: "client-notify-cancel", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + now := time.Now() + if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", created.Order.ID).Updates(map[string]interface{}{ + "order_status": model.OrderStatusCancelled, + "cancelled_at": now, + }).Error; err != nil { + t.Fatalf("cancel order: %v", err) + } + _, err = svc.HandleShipNotify(ShipNotifyInput{ + OrderNo: created.Order.OrderNo, + ShipStatus: "success", + }) + if !errors.Is(err, ErrShipNotifyRejected) { + t.Fatalf("expected ErrShipNotifyRejected, got %v", err) + } +} diff --git a/backend/internal/service/provider_dlc.go b/backend/internal/service/provider_dlc.go index 1555af4..95b051f 100644 --- a/backend/internal/service/provider_dlc.go +++ b/backend/internal/service/provider_dlc.go @@ -197,18 +197,30 @@ func (p *DLCProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, goodsNo := sku goodsName := order.ProductName - queueOrderID, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo) - if err != nil { - return nil, fmt.Errorf("创建发货队列失败: %w", err) + queueOrderID := resultDataString(order.ResultData, "queue_order_id") + partial := map[string]interface{}{ + "provider": ProviderDLC, + } + if queueOrderID == "" { + createdID, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo) + if err != nil { + return nil, fmt.Errorf("创建发货队列失败: %w", err) + } + queueOrderID = createdID + partial["queue_order_id"] = queueOrderID + partial["provider_order_stage"] = deliveryStageQueueCreated + } else { + partial["queue_order_id"] = queueOrderID + partial["provider_order_stage"] = deliveryStageQueueCreated } if err := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil { - return nil, fmt.Errorf("补充发货账号失败: %w", err) + return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("补充发货账号失败: %w", err) } upstreamOrder, err := p.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo) if err != nil { - return nil, fmt.Errorf("创建上游正式订单失败: %w", err) + return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("创建上游正式订单失败: %w", err) } providerOrderNo := firstNonEmpty( diff --git a/backend/internal/service/provider_lewan.go b/backend/internal/service/provider_lewan.go index dee3b38..1223810 100644 --- a/backend/internal/service/provider_lewan.go +++ b/backend/internal/service/provider_lewan.go @@ -303,6 +303,10 @@ func (p *LewanProvider) Submit(ctx context.Context, order *model.FulfillmentOrde return nil, errors.New("未获取到乐玩订单") } + if lewanOrder.Status == "WAITING_BINDING" { + return nil, errors.New("角色尚未绑定完成,请先完成扫码绑定后再提交发货") + } + providerOrderNo := lewanOrder.OrderNo if providerOrderNo == "" { providerOrderNo = lewanOrder.ID diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 33d8454..7e95451 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -485,8 +485,8 @@ export default function Delivery() { )} - {/* 状态 3:发货失败 */} - {order.status === 'ship_failed' && ( + {/* 状态 3:发货失败且不可自动重试 */} + {order.status === 'ship_failed' && !order.can_ship && (
)} - {/* 状态 4:待绑定扫码 */} - {order.status === 'paid' && ( + {/* 状态 4:待绑定扫码(含未提交上游的 ship_failed 可重试) */} + {(order.status === 'paid' || (order.status === 'ship_failed' && order.can_ship)) && (
+ {order.status === 'ship_failed' && ( + + )}