diff --git a/README.md b/README.md index 05776ae..d9c9e50 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,6 @@ make docker-down # 停止并移除 | 订单状态 | 含义 | 是否可发货 | |----------|------|------------| -| `pending` | 待支付(当前无真实支付流程,仅预留) | 否 | | `paid` | 已支付/已扣款,待发货 | 是 | | `delivering` | 发货中 | 否 | | `delivered` | 已交付 | 否 | diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index d2518a4..13d0f63 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -34,7 +34,6 @@ const ( ProductStatusActive = "active" ProductStatusInactive = "inactive" - OrderStatusPending = "pending" // 待支付(当前无真实支付流程,仅预留) OrderStatusPaid = "paid" // 已扣款,待发货 OrderStatusDelivering = "delivering" // 发货中 OrderStatusDelivered = "delivered" // 已交付 diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 240823b..b4ca4c0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -82,10 +82,10 @@ func Setup(h *Handlers) *gin.Engine { { clientOpen.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireAPIScope("products:read"), h.Open.ListProducts) clientOpen.POST("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CreateOrder) - clientOpen.GET("/orders/:order_no", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.QueryOrder) + clientOpen.GET("/orders/:order_no", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "shipping:read"), h.Open.QueryOrder) clientOpen.POST("/orders/:order_no/cancel", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder) - clientOpen.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.GetDeliveryLink) - clientOpen.GET("/orders/:order_no/delivery", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.GetDeliveryOrder) + clientOpen.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "shipping:read"), h.Open.GetDeliveryLink) + clientOpen.GET("/orders/:order_no/delivery", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:read", "shipping:read"), h.Open.GetDeliveryOrder) clientOpen.POST("/orders/:order_no/delivery/bind", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.BindDeliveryOrder) clientOpen.POST("/orders/:order_no/delivery/submit", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireAPIScope("orders:write"), h.Open.SubmitDeliveryOrder) clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 3c4dcfd..1363151 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -24,6 +24,34 @@ import ( const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg" +// 发货提交阶段,持久化在 result_data.provider_order_stage 中,供排障与补偿扫描区分处理。 +const ( + deliveryStageClaimed = "claimed" // 已锁定订单,尚未建 queue + deliveryStageQueueCreated = "queue_created" // 已在上游建 queue,尚未生成正式订单 + deliveryStageSubmitted = "submitted" // 已在上游创建正式订单 + deliveryStageBindVerify = "bind_verify" // 绑定账号校验失败 + deliveryStageBindMismatch = "bind_mismatch" // 玩家编号与绑定账号不一致 + deliveryStageCreateQueue = "create_queue" // 创建上游 queue 失败 + deliveryStagePatchQueue = "patch_queue" // 补充 queue 账号信息失败 + deliveryStageCreateOrder = "create_upstream" // 创建上游正式订单失败 +) + +// deliverySubmittedUpstream 判断订单是否已成功提交到上游正式订单。 +// 已提交上游的失败订单禁止自动重发,避免重复发货。 +func deliverySubmittedUpstream(order *model.FulfillmentOrder) bool { + if order == nil { + return false + } + if strings.TrimSpace(order.ProviderOrderNo) != "" { + return true + } + m := resultDataMap(order.ResultData) + if _, ok := m["upstream_order"]; ok { + return true + } + return resultDataString(order.ResultData, "provider_order_stage") == deliveryStageSubmitted +} + type DeliveryService struct { fulfillment *FulfillmentService bffBaseURL string @@ -251,6 +279,10 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien return existing, nil } } + // 已在上游创建正式订单的失败订单禁止自动重发,避免重复发货。 + if orderStatus == model.OrderStatusShipFailed && deliverySubmittedUpstream(order) { + return nil, newDeliveryHTTPError(http.StatusConflict, "该订单已提交上游,为避免重复发货不能直接重试,请先在上游确认订单状态或联系平台处理") + } if !info.CanShip { reason := info.CannotShipReason if reason == "" { @@ -270,26 +302,39 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien } boundAccount, err := s.accountBound(bindUUID, goodID) if err != nil { - s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) + s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindVerify, err.Error()) return nil, err } if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount { err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定") - s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) + s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindMismatch, err.Error()) return nil, err } queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo) if err != nil { - s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) + s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateQueue, err.Error()) + return nil, err + } + // 建 queue 后立即持久化,缩小崩溃窗口:即使后续崩溃,也能识别到已建 queue 的阶段。 + if _, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: order.MerchantID, + APIClientID: apiClientID, + OrderNo: order.OrderNo, + Status: model.OrderStatusDelivering, + ResultData: mergeResultData(claimed.ResultData, map[string]interface{}{ + "provider_order_stage": deliveryStageQueueCreated, + "queue_order_id": queueOrderID, + }), + }); err != nil { return nil, err } if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil { - s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) + s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStagePatchQueue, err.Error()) return nil, err } upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo) if err != nil { - s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) + s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, err.Error()) return nil, err } providerOrderNo := firstNonEmpty( @@ -299,15 +344,15 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien stringFromMap(upstreamOrder, "id"), queueOrderID, ) - resultData := map[string]interface{}{ - "source": "delivery_proxy", - "queue_order_id": queueOrderID, - "provider_order_no": providerOrderNo, - "game_uid": gameAccount, - "role_name": stringFromMap(boundAccount, "game_account_role_name"), - "game_channel": gameChannelText(boundAccount), - "upstream_order": upstreamOrder, - } + resultData := mergeResultData(claimed.ResultData, map[string]interface{}{ + "provider_order_stage": deliveryStageSubmitted, + "queue_order_id": queueOrderID, + "provider_order_no": providerOrderNo, + "game_uid": gameAccount, + "role_name": stringFromMap(boundAccount, "game_account_role_name"), + "game_channel": gameChannelText(boundAccount), + "upstream_order": upstreamOrder, + }) updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: order.MerchantID, APIClientID: apiClientID, @@ -350,14 +395,15 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil { return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货") } - raw, _ := json.Marshal(map[string]interface{}{ + patch := map[string]interface{}{ "source": "delivery_proxy", "submit_started_at": timeutil.FormatAPITime(time.Now()), - "provider_order_stage": "claimed", - }) + "provider_order_stage": deliveryStageClaimed, + "ship_attempts": resultDataNumber(order.ResultData, "ship_attempts") + 1, + } if err := tx.Model(&order).Updates(map[string]interface{}{ "order_status": model.OrderStatusDelivering, - "result_data": string(raw), + "result_data": mergeResultData(order.ResultData, patch), "failure_reason": "", }).Error; err != nil { return err @@ -371,7 +417,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, return err } if s.fulfillment.callbacks != nil { - if err := s.fulfillment.callbacks.Enqueue(tx, merchantID, "order.fulfillment.updated", orderCallbackData(&updated)); err != nil { + if err := s.fulfillment.callbacks.Enqueue(tx, merchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil { return err } } @@ -383,20 +429,27 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, return &updated, claimedNow, nil } -func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, reason string) { +func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, stage, reason string) { if order == nil { return } + latest := order + if fresh, err := s.fulfillment.GetOrder(order.MerchantID, order.OrderNo); err == nil { + latest = fresh + } + resultData := mergeResultData(latest.ResultData, map[string]interface{}{ + "provider_order_stage": stage, + "fail_stage": stage, + "fail_reason": reason, + "fail_at": timeutil.FormatAPITime(time.Now()), + }) _, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: order.MerchantID, APIClientID: apiClientID, OrderNo: order.OrderNo, Status: model.OrderStatusShipFailed, FailureReason: reason, - ResultData: map[string]interface{}{ - "source": "delivery_proxy", - "fail_reason": reason, - }, + ResultData: resultData, }) } diff --git a/backend/internal/service/delivery_test.go b/backend/internal/service/delivery_test.go index cd222d7..f015d83 100644 --- a/backend/internal/service/delivery_test.go +++ b/backend/internal/service/delivery_test.go @@ -243,3 +243,125 @@ func TestDeliveryMerchantApiBindAndSubmit(t *testing.T) { t.Fatalf("unexpected mock counts bind=%d bound=%d patch=%d", bindCalls, boundCalls, queuePatchCalls) } } + +func TestDeliveryResubmitBlockedAfterUpstreamSubmission(t *testing.T) { + db := newServiceTestDB(t) + merchantID, _ := seedFulfillmentMerchant(t, db, "delivery-guard", 5000, 5, 100) + product := model.Product{Code: "delivery-guard-catalog", Name: "测试发货商品", Status: model.ProductStatusActive} + if err := db.Create(&product).Error; err != nil { + t.Fatalf("create product: %v", err) + } + if err := db.Create(&model.MerchantProduct{ + MerchantID: merchantID, + ProductID: product.ID, + SKU: "suit_alan_walker", + DisplayName: "测试发货商品", + PriceAmount: 100, + Currency: "POINT", + Stock: 5, + Status: model.ProductStatusActive, + }).Error; err != nil { + t.Fatalf("create merchant product: %v", err) + } + + var queueCreateCalls, upstreamCalls int32 + mockBFF := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/sign-proxy" { + w.WriteHeader(http.StatusNotFound) + return + } + var payload struct { + Path string `json:"path"` + Method string `json:"method"` + Data json.RawMessage `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + switch payload.Path { + case "/public/goods/detail": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"good": map[string]interface{}{"title": "测试发货商品"}}, + }) + case "/public/games/bind-account": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"bind_uuid": "bind-1", "url": "https://bind.example/qr"}, + }) + case "/public/games/account-bound": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gameAccount": map[string]interface{}{ + "game_account": "4808146277", + "game_account_role_name": "角色甲", + "game_account_area": "安卓", + "game_account_plat": "QQ", + }, + }, + }) + case "/public/users/orders-queue": + if payload.Method == http.MethodPost { + atomic.AddInt32(&queueCreateCalls, 1) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"orders": []map[string]interface{}{{"_id": "queue-g1"}}}, + }) + return + } + if payload.Method == http.MethodPatch { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]interface{}{}}) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + case "/public/users/orders": + atomic.AddInt32(&upstreamCalls, 1) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"order": map[string]interface{}{"order_no": "provider-g1"}}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockBFF.Close() + + fulfillmentSvc := NewFulfillmentService(db, nil) + deliverySvc := NewDeliveryService(fulfillmentSvc, mockBFF.URL, "dlc", "https://shop.example", "link-secret", 60) + + created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 1, + ClientOrderNo: "delivery-guard-001", + SKU: "suit_alan_walker", + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + if _, err := deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", "bind-1"); err != nil { + t.Fatalf("submit for merchant: %v", err) + } + if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 { + t.Fatalf("first submit should create upstream once, queue=%d upstream=%d", queueCreateCalls, upstreamCalls) + } + + // 模拟超时巡检把已提交上游的订单标记为发货失败(保留 provider_order_no 与 result_data 上下文)。 + if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", created.Order.ID). + Update("order_status", model.OrderStatusShipFailed).Error; err != nil { + t.Fatalf("mark ship_failed: %v", err) + } + + _, err = deliverySvc.SubmitForMerchant(merchantID, 88, created.Order.OrderNo, "4808146277", "bind-1") + if err == nil { + t.Fatalf("resubmit after upstream submission should be rejected") + } + if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != http.StatusConflict { + t.Fatalf("expected 409 for resubmit guard, got %v", err) + } + if atomic.LoadInt32(&queueCreateCalls) != 1 || atomic.LoadInt32(&upstreamCalls) != 1 { + t.Fatalf("guard should prevent new upstream order, queue=%d upstream=%d", queueCreateCalls, upstreamCalls) + } +} diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 44c4f77..748e389 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "math" + "strconv" "strings" "time" "unicode/utf8" @@ -418,7 +419,7 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode return err } if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil { + if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil { return err } } @@ -476,10 +477,16 @@ func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duratio if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil { return nil } - reason := fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes())) + submittedUpstream := deliverySubmittedUpstream(&order) + var reason string + if submittedUpstream { + reason = fmt.Sprintf("发货超时:订单已提交上游但超过 %d 分钟未回传结果,可能仍在处理;请勿直接重试,先在上游确认订单状态", int(timeout.Minutes())) + } else { + reason = fmt.Sprintf("发货超时:发货提交中断,请重新提交(已停留 delivering 超过 %d 分钟)", int(timeout.Minutes())) + } updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed} updates["failure_reason"] = reason - updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now) + updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now, reason) if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } @@ -496,7 +503,7 @@ func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duratio return err } if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil { + if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil { return err } } @@ -734,10 +741,13 @@ func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAm func CanFulfill(order *model.FulfillmentOrder) (bool, string) { switch normalizeOrderStatus(order) { - case model.OrderStatusPaid, model.OrderStatusShipFailed: + case model.OrderStatusPaid: + return true, "" + case model.OrderStatusShipFailed: + if deliverySubmittedUpstream(order) { + return false, "订单已提交上游,为避免重复发货请先确认上游状态" + } return true, "" - case model.OrderStatusPending: - return false, "订单未支付" case model.OrderStatusDelivering: return false, "订单发货中" case model.OrderStatusDelivered: @@ -782,16 +792,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"` - PendingOrderCount int64 `json:"pending_order_count"` - 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"` @@ -922,8 +931,6 @@ func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (* } for _, item := range orderStatusCounts { switch item.Status { - case model.OrderStatusPending: - stats.PendingOrderCount = item.Count case model.OrderStatusPaid: stats.PaidOrderCount = item.Count case model.OrderStatusDelivering: @@ -1133,9 +1140,6 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe case "success": if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { message := "当前状态不允许标记发货成功" - if normalizeOrderStatus(&order) == model.OrderStatusPending { - message = "订单未支付,拒绝成功推送" - } if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { return err } @@ -1153,8 +1157,6 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe message := "当前状态不允许标记发货失败" if normalizeOrderStatus(&order) == model.OrderStatusDelivered { message = "订单已交付,拒绝失败推送" - } else if normalizeOrderStatus(&order) == model.OrderStatusPending { - message = "订单未支付,拒绝失败推送" } if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { return err @@ -1185,7 +1187,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe return err } if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.fulfillment.updated", orderCallbackData(&updated)); err != nil { + if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil { return err } } @@ -1259,16 +1261,28 @@ func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *t return string(raw) } -func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time) string { +func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time, reason string) string { + return mergeResultData(existing, map[string]interface{}{ + "timeout": true, + "timeout_minutes": int(timeout.Minutes()), + "timeout_at": timeutil.FormatAPITime(now), + "ship_status": "failed", + "fail_reason": reason, + }) +} + +// mergeResultData 保留已有 JSON 字段,仅覆盖或新增 patch 中的字段,避免各发货阶段互相清空上下文。 +func mergeResultData(existing string, patch map[string]interface{}) string { + if len(patch) == 0 { + return existing + } m := map[string]interface{}{} if existing != "" && json.Valid([]byte(existing)) { _ = json.Unmarshal([]byte(existing), &m) } - m["timeout"] = true - m["timeout_minutes"] = int(timeout.Minutes()) - m["timeout_at"] = timeutil.FormatAPITime(now) - m["ship_status"] = "failed" - m["fail_reason"] = fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes())) + for k, v := range patch { + m[k] = v + } raw, err := json.Marshal(m) if err != nil { return existing @@ -1276,6 +1290,37 @@ func buildProcessingTimeoutResultData(existing string, timeout time.Duration, no return string(raw) } +func resultDataMap(raw string) map[string]interface{} { + m := map[string]interface{}{} + if raw != "" && json.Valid([]byte(raw)) { + _ = json.Unmarshal([]byte(raw), &m) + } + return m +} + +func resultDataString(raw, key string) string { + if v, ok := resultDataMap(raw)[key].(string); ok { + return v + } + return "" +} + +func resultDataNumber(raw, key string) int64 { + switch v := resultDataMap(raw)[key].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + return 0 +} + // extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。 func extractGameFields(raw string, out *OpenOrderQuery) { if raw == "" || !json.Valid([]byte(raw)) { diff --git a/backend/internal/service/fulfillment_state.go b/backend/internal/service/fulfillment_state.go index 6c8953f..4d20b00 100644 --- a/backend/internal/service/fulfillment_state.go +++ b/backend/internal/service/fulfillment_state.go @@ -82,11 +82,6 @@ func validateOrderStatusTransition(order *model.FulfillmentOrder, next string, k return nil } switch current { - case model.OrderStatusPending: - if next == model.OrderStatusPaid || next == model.OrderStatusCancelled { - return nil - } - return errors.New("订单未支付,不能发货") case model.OrderStatusCancelled: return errors.New("订单已取消,不能更新状态") case model.OrderStatusDelivered: @@ -131,8 +126,6 @@ func canCancelOrder(order *model.FulfillmentOrder) error { switch status { case model.OrderStatusPaid, model.OrderStatusShipFailed: return nil - case model.OrderStatusPending: - return errors.New("订单未支付,不能取消") case model.OrderStatusDelivering, model.OrderStatusDelivered: return errors.New("订单已进入发货流程,不能取消") default: diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index e049ada..f8fe594 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "strings" "testing" "time" @@ -519,6 +520,113 @@ func TestMarkProcessingTimeoutsSkipsRecentOrders(t *testing.T) { } } +func TestMergeResultDataKeepsHistoricalFields(t *testing.T) { + merged := mergeResultData(`{"a":1,"stage":"claimed","nested":{"x":true}}`, map[string]interface{}{ + "stage": "submitted", + "b": 2, + }) + var m map[string]interface{} + if err := json.Unmarshal([]byte(merged), &m); err != nil { + t.Fatalf("merge result should be valid json: %v", err) + } + if m["stage"] != "submitted" || m["a"] != float64(1) || m["b"] != float64(2) { + t.Fatalf("merge should override patched keys and keep others, got %s", merged) + } + nested, ok := m["nested"].(map[string]interface{}) + if !ok || nested["x"] != true { + t.Fatalf("merge should keep nested objects, got %s", merged) + } + if got := mergeResultData("not-json", map[string]interface{}{"k": "v"}); got == "not-json" { + t.Fatalf("invalid existing json should be replaced, got %s", got) + } + if got := mergeResultData(`{"k":"v"}`, nil); got != `{"k":"v"}` { + t.Fatalf("empty patch should keep existing, got %s", got) + } +} + +func TestMarkProcessingTimeoutDistinguishesUpstreamSubmission(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout-kind", 1000, -1, 100) + svc := NewFulfillmentService(db, nil) + + submittedOrder, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 13, + ClientOrderNo: "client-timeout-submitted", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create submitted order: %v", err) + } + if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: submittedOrder.Order.OrderNo, + Status: model.OrderStatusDelivering, + ResultData: map[string]interface{}{ + "provider_order_stage": "submitted", + "provider_order_no": "provider-1", + "upstream_order": map[string]interface{}{"order_id": "up-1"}, + }, + }); err != nil { + t.Fatalf("mark submitted delivering: %v", err) + } + + interruptedOrder, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 13, + ClientOrderNo: "client-timeout-interrupted", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create interrupted order: %v", err) + } + if _, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: interruptedOrder.Order.OrderNo, + Status: model.OrderStatusDelivering, + ResultData: map[string]interface{}{ + "provider_order_stage": "claimed", + }, + }); err != nil { + t.Fatalf("mark claimed delivering: %v", err) + } + + cutoff := time.Now().Add(-time.Hour) + for _, id := range []uint{submittedOrder.Order.ID, interruptedOrder.Order.ID} { + if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", id).Update("updated_at", cutoff).Error; err != nil { + t.Fatalf("age order %d: %v", id, err) + } + } + changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10) + if err != nil { + t.Fatalf("mark timeouts: %v", err) + } + if changed != 2 { + t.Fatalf("expected both orders timed out, got %d", changed) + } + + var submitted model.FulfillmentOrder + if err := db.First(&submitted, submittedOrder.Order.ID).Error; err != nil { + t.Fatalf("query submitted order: %v", err) + } + if submitted.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(submitted.FailureReason, "已提交上游") { + t.Fatalf("submitted-upstream order should warn about upstream, got %+v", submitted) + } + if !strings.Contains(submitted.ResultData, `"provider_order_no":"provider-1"`) { + t.Fatalf("submitted-upstream order should keep context, got %s", submitted.ResultData) + } + + var interrupted model.FulfillmentOrder + if err := db.First(&interrupted, interruptedOrder.Order.ID).Error; err != nil { + t.Fatalf("query interrupted order: %v", err) + } + if interrupted.OrderStatus != model.OrderStatusShipFailed || !strings.Contains(interrupted.FailureReason, "提交中断") { + t.Fatalf("interrupted order should say retryable, got %+v", interrupted) + } +} + func TestFulfillmentMerchantIsolation(t *testing.T) { db := newServiceTestDB(t) merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100) @@ -580,6 +688,13 @@ func TestCanFulfill(t *testing.T) { if !ok || reason != "" { t.Fatalf("failed paid order should be fulfillable") } + ok, reason = CanFulfill(&model.FulfillmentOrder{ + OrderStatus: model.OrderStatusShipFailed, + ProviderOrderNo: "provider-1", + }) + if ok || !strings.Contains(reason, "已提交上游") { + t.Fatalf("failed order already submitted upstream should not be re-fulfillable, ok=%v reason=%q", ok, reason) + } ok, _ = CanFulfill(&model.FulfillmentOrder{ OrderStatus: model.OrderStatusCancelled, }) @@ -711,7 +826,7 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) { if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{ Name: "发货回调", URL: "https://example.com/callback", - Events: "order.fulfillment.updated", + Events: "order.shipping.updated", }, 7); err != nil { t.Fatalf("create callback subscription: %v", err) } @@ -737,7 +852,7 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) { t.Fatalf("unexpected order after source notify: %+v", order) } var delivery model.CallbackDelivery - if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.fulfillment.updated").First(&delivery).Error; err != nil { + if err := db.Where("merchant_id = ? AND event = ?", merchantID, "order.shipping.updated").First(&delivery).Error; err != nil { t.Fatalf("query callback delivery: %v", err) } if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") { diff --git a/docs/API对接关系.md b/docs/API对接关系.md index 2dd4d75..e3c8991 100644 --- a/docs/API对接关系.md +++ b/docs/API对接关系.md @@ -55,7 +55,6 @@ | order_status | 含义 | 是否可发货 | |--------------|------|------------| -| `pending` | 待支付(当前无真实支付流程,仅预留) | 否 | | `paid` | 已支付/已扣款,待发货 | 是 | | `delivering` | 发货中 | 否 | | `delivered` | 已交付 | 否 | diff --git a/docs/开放接口-皮肤源头对接.md b/docs/开放接口-皮肤源头对接.md index c424f11..2f7154f 100644 --- a/docs/开放接口-皮肤源头对接.md +++ b/docs/开放接口-皮肤源头对接.md @@ -197,7 +197,6 @@ GET /api/open/v1/orders/{order_no} | status | 含义 | can_ship | |--------|------|----------| -| `pending` | 待支付 | **false** | | `paid` | 已支付 | **true** ← 正常可发 | | `delivering` | 发货中 | false | | `delivered` | 已交付 | false | diff --git a/frontend/src/openapi/endpoints.ts b/frontend/src/openapi/endpoints.ts index d6cc0ff..f0773d0 100644 --- a/frontend/src/openapi/endpoints.ts +++ b/frontend/src/openapi/endpoints.ts @@ -18,7 +18,6 @@ export const errorCodes: ParamSpec[] = [ // 订单状态与可发货说明。 export const orderStatusTable: ParamSpec[] = [ - { name: 'pending', type: 'order_status', desc: '待支付(当前无真实支付流程,仅预留)', example: 'can_ship=false' }, { name: 'paid', type: 'order_status', desc: '已支付/已扣款,可发货', example: 'can_ship=true' }, { name: 'delivering', type: 'order_status', desc: '发货中', example: 'can_ship=false' }, { name: 'delivered', type: 'order_status', desc: '已交付', example: 'can_ship=false' }, @@ -263,7 +262,7 @@ export const endpoints: EndpointSpec[] = [ path: '/api/client/v1/orders/{order_no}/delivery-link', title: '获取发货链接', summary: '获取签名发货链接,适合直接跳转平台 Web 发货页', - scope: 'orders:read + fulfillment:read', + scope: 'orders:read 或 shipping:read', pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], responseExample: deliveryLinkResponseExample, responseFields: [ @@ -284,7 +283,7 @@ export const endpoints: EndpointSpec[] = [ path: '/api/client/v1/orders/{order_no}/delivery', title: '查询发货数据', summary: '查询发货页结构化数据,适合商户自建发货界面', - scope: 'orders:read + fulfillment:read', + scope: 'orders:read 或 shipping:read', pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], responseExample: deliveryOrderResponseExample, responseFields: [ diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b36664b..36d9f7f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -161,12 +161,6 @@ export default function Dashboard() { - = { - pending: { color: 'orange', text: '待支付' }, paid: { color: 'blue', text: '待发货' }, delivering: { color: 'cyan', text: '发货中' }, delivered: { color: 'green', text: '已交付' }, @@ -61,13 +60,13 @@ const scopeOptions = [ { value: 'products:read', label: '商品读取' }, { value: 'orders:read', label: '订单读取' }, { value: 'orders:write', label: '订单写入' }, - { value: 'fulfillment:read', label: '发货读取' }, + { value: 'shipping:read', label: '发货读取' }, { value: 'wallet:read', label: '钱包读取' }, ] const eventOptions = [ { value: 'order.created', label: '订单创建' }, - { value: 'order.fulfillment.updated', label: '发货更新' }, + { value: 'order.shipping.updated', label: '发货更新' }, { value: 'order.cancelled', label: '订单取消' }, ] @@ -665,7 +664,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer 回调事件通过 outbox 持久化发送。 {canManage && } diff --git a/frontend/src/pages/OpenApiDocs.tsx b/frontend/src/pages/OpenApiDocs.tsx index be08906..94e2e43 100644 --- a/frontend/src/pages/OpenApiDocs.tsx +++ b/frontend/src/pages/OpenApiDocs.tsx @@ -489,7 +489,7 @@ POST /orders 下单(client_order_no 幂等) ↓ 发货平台通过上游回调更新 order_status ↓ -GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`} +GET /orders/{order_no} 轮询,或接收 order.shipping.updated 回调`} } /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c0b764b..e1112d5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -220,7 +220,6 @@ export interface DashboardStats { today_sales: number total_fees: number today_fees: number - pending_order_count: number paid_order_count: number delivering_order_count: number delivered_order_count: number