加强发货提交可靠性并统一契约口径

- 发货提交记录阶段推进与失败分类,失败不再清空 result_data
- 超时巡检区分已提交上游与提交中断两类卡单,避免误判
- 已提交上游的失败订单禁止自动重发,防止重复发货
- ship_attempts 仅在 claim 时计数,失败阶段只记录分类信息
- CanFulfill 对已提交上游的失败单返回不可发货
- 删除预留的 pending 订单状态,统一订单状态模型
- 契约改名:order.fulfillment.updated -> order.shipping.updated,fulfillment:read -> shipping:read
- 文档修正 scope 为或关系
This commit is contained in:
yml2213
2026-07-31 18:42:52 +08:00
parent 4aa5258c2d
commit eac025c92f
15 changed files with 401 additions and 86 deletions
-1
View File
@@ -128,7 +128,6 @@ make docker-down # 停止并移除
| 订单状态 | 含义 | 是否可发货 | | 订单状态 | 含义 | 是否可发货 |
|----------|------|------------| |----------|------|------------|
| `pending` | 待支付(当前无真实支付流程,仅预留) | 否 |
| `paid` | 已支付/已扣款,待发货 | 是 | | `paid` | 已支付/已扣款,待发货 | 是 |
| `delivering` | 发货中 | 否 | | `delivering` | 发货中 | 否 |
| `delivered` | 已交付 | 否 | | `delivered` | 已交付 | 否 |
-1
View File
@@ -34,7 +34,6 @@ const (
ProductStatusActive = "active" ProductStatusActive = "active"
ProductStatusInactive = "inactive" ProductStatusInactive = "inactive"
OrderStatusPending = "pending" // 待支付(当前无真实支付流程,仅预留)
OrderStatusPaid = "paid" // 已扣款,待发货 OrderStatusPaid = "paid" // 已扣款,待发货
OrderStatusDelivering = "delivering" // 发货中 OrderStatusDelivering = "delivering" // 发货中
OrderStatusDelivered = "delivered" // 已交付 OrderStatusDelivered = "delivered" // 已交付
+3 -3
View File
@@ -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.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.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.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-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", "fulfillment:read"), h.Open.GetDeliveryOrder) 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/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.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) clientOpen.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet)
+77 -24
View File
@@ -24,6 +24,34 @@ import (
const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg" 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 { type DeliveryService struct {
fulfillment *FulfillmentService fulfillment *FulfillmentService
bffBaseURL string bffBaseURL string
@@ -251,6 +279,10 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
return existing, nil return existing, nil
} }
} }
// 已在上游创建正式订单的失败订单禁止自动重发,避免重复发货。
if orderStatus == model.OrderStatusShipFailed && deliverySubmittedUpstream(order) {
return nil, newDeliveryHTTPError(http.StatusConflict, "该订单已提交上游,为避免重复发货不能直接重试,请先在上游确认订单状态或联系平台处理")
}
if !info.CanShip { if !info.CanShip {
reason := info.CannotShipReason reason := info.CannotShipReason
if reason == "" { if reason == "" {
@@ -270,26 +302,39 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
} }
boundAccount, err := s.accountBound(bindUUID, goodID) boundAccount, err := s.accountBound(bindUUID, goodID)
if err != nil { if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindVerify, err.Error())
return nil, err return nil, err
} }
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount { if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定") err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindMismatch, err.Error())
return nil, err return nil, err
} }
queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo) queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo)
if err != nil { 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 return nil, err
} }
if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil { 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 return nil, err
} }
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo) upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
if err != nil { if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, err.Error()) s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, err.Error())
return nil, err return nil, err
} }
providerOrderNo := firstNonEmpty( providerOrderNo := firstNonEmpty(
@@ -299,15 +344,15 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
stringFromMap(upstreamOrder, "id"), stringFromMap(upstreamOrder, "id"),
queueOrderID, queueOrderID,
) )
resultData := map[string]interface{}{ resultData := mergeResultData(claimed.ResultData, map[string]interface{}{
"source": "delivery_proxy", "provider_order_stage": deliveryStageSubmitted,
"queue_order_id": queueOrderID, "queue_order_id": queueOrderID,
"provider_order_no": providerOrderNo, "provider_order_no": providerOrderNo,
"game_uid": gameAccount, "game_uid": gameAccount,
"role_name": stringFromMap(boundAccount, "game_account_role_name"), "role_name": stringFromMap(boundAccount, "game_account_role_name"),
"game_channel": gameChannelText(boundAccount), "game_channel": gameChannelText(boundAccount),
"upstream_order": upstreamOrder, "upstream_order": upstreamOrder,
} })
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID, MerchantID: order.MerchantID,
APIClientID: apiClientID, APIClientID: apiClientID,
@@ -350,14 +395,15 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil { if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil {
return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货") return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货")
} }
raw, _ := json.Marshal(map[string]interface{}{ patch := map[string]interface{}{
"source": "delivery_proxy", "source": "delivery_proxy",
"submit_started_at": timeutil.FormatAPITime(time.Now()), "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{}{ if err := tx.Model(&order).Updates(map[string]interface{}{
"order_status": model.OrderStatusDelivering, "order_status": model.OrderStatusDelivering,
"result_data": string(raw), "result_data": mergeResultData(order.ResultData, patch),
"failure_reason": "", "failure_reason": "",
}).Error; err != nil { }).Error; err != nil {
return err return err
@@ -371,7 +417,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
return err return err
} }
if s.fulfillment.callbacks != nil { 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 return err
} }
} }
@@ -383,20 +429,27 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
return &updated, claimedNow, nil 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 { if order == nil {
return 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{ _, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID, MerchantID: order.MerchantID,
APIClientID: apiClientID, APIClientID: apiClientID,
OrderNo: order.OrderNo, OrderNo: order.OrderNo,
Status: model.OrderStatusShipFailed, Status: model.OrderStatusShipFailed,
FailureReason: reason, FailureReason: reason,
ResultData: map[string]interface{}{ ResultData: resultData,
"source": "delivery_proxy",
"fail_reason": reason,
},
}) })
} }
+122
View File
@@ -243,3 +243,125 @@ func TestDeliveryMerchantApiBindAndSubmit(t *testing.T) {
t.Fatalf("unexpected mock counts bind=%d bound=%d patch=%d", bindCalls, boundCalls, queuePatchCalls) 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)
}
}
+76 -31
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"log" "log"
"math" "math"
"strconv"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -418,7 +419,7 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
return err return err
} }
if s.callbacks != nil { 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 return err
} }
} }
@@ -476,10 +477,16 @@ func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duratio
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil { if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
return 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 := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
updates["failure_reason"] = reason 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 { if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err return err
} }
@@ -496,7 +503,7 @@ func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duratio
return err return err
} }
if s.callbacks != nil { 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 return err
} }
} }
@@ -734,10 +741,13 @@ func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAm
func CanFulfill(order *model.FulfillmentOrder) (bool, string) { func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
switch normalizeOrderStatus(order) { switch normalizeOrderStatus(order) {
case model.OrderStatusPaid, model.OrderStatusShipFailed: case model.OrderStatusPaid:
return true, ""
case model.OrderStatusShipFailed:
if deliverySubmittedUpstream(order) {
return false, "订单已提交上游,为避免重复发货请先确认上游状态"
}
return true, "" return true, ""
case model.OrderStatusPending:
return false, "订单未支付"
case model.OrderStatusDelivering: case model.OrderStatusDelivering:
return false, "订单发货中" return false, "订单发货中"
case model.OrderStatusDelivered: case model.OrderStatusDelivered:
@@ -782,16 +792,15 @@ type DashboardStats struct {
UserCount int64 `json:"user_count"` UserCount int64 `json:"user_count"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
TodayOrderCount int64 `json:"today_order_count"` TodayOrderCount int64 `json:"today_order_count"`
TotalSales int64 `json:"total_sales"` TotalSales int64 `json:"total_sales"`
TodaySales int64 `json:"today_sales"` TodaySales int64 `json:"today_sales"`
TotalFees int64 `json:"total_fees"` TotalFees int64 `json:"total_fees"`
TodayFees int64 `json:"today_fees"` TodayFees int64 `json:"today_fees"`
PendingOrderCount int64 `json:"pending_order_count"` PaidOrderCount int64 `json:"paid_order_count"`
PaidOrderCount int64 `json:"paid_order_count"` DeliveringOrderCount int64 `json:"delivering_order_count"`
DeliveringOrderCount int64 `json:"delivering_order_count"` DeliveredOrderCount int64 `json:"delivered_order_count"`
DeliveredOrderCount int64 `json:"delivered_order_count"` ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
ShipFailedOrderCount int64 `json:"ship_failed_order_count"` CancelledOrderCount int64 `json:"cancelled_order_count"`
CancelledOrderCount int64 `json:"cancelled_order_count"`
WalletAvailableBalance int64 `json:"wallet_available_balance"` WalletAvailableBalance int64 `json:"wallet_available_balance"`
WalletFrozenBalance int64 `json:"wallet_frozen_balance"` WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
APIClientCount int64 `json:"api_client_count"` APIClientCount int64 `json:"api_client_count"`
@@ -922,8 +931,6 @@ func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*
} }
for _, item := range orderStatusCounts { for _, item := range orderStatusCounts {
switch item.Status { switch item.Status {
case model.OrderStatusPending:
stats.PendingOrderCount = item.Count
case model.OrderStatusPaid: case model.OrderStatusPaid:
stats.PaidOrderCount = item.Count stats.PaidOrderCount = item.Count
case model.OrderStatusDelivering: case model.OrderStatusDelivering:
@@ -1133,9 +1140,6 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
case "success": case "success":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货成功" message := "当前状态不允许标记发货成功"
if normalizeOrderStatus(&order) == model.OrderStatusPending {
message = "订单未支付,拒绝成功推送"
}
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err return err
} }
@@ -1153,8 +1157,6 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
message := "当前状态不允许标记发货失败" message := "当前状态不允许标记发货失败"
if normalizeOrderStatus(&order) == model.OrderStatusDelivered { if normalizeOrderStatus(&order) == model.OrderStatusDelivered {
message = "订单已交付,拒绝失败推送" message = "订单已交付,拒绝失败推送"
} else if normalizeOrderStatus(&order) == model.OrderStatusPending {
message = "订单未支付,拒绝失败推送"
} }
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err return err
@@ -1185,7 +1187,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
return err return err
} }
if s.callbacks != nil { 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 return err
} }
} }
@@ -1259,16 +1261,28 @@ func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *t
return string(raw) 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{}{} m := map[string]interface{}{}
if existing != "" && json.Valid([]byte(existing)) { if existing != "" && json.Valid([]byte(existing)) {
_ = json.Unmarshal([]byte(existing), &m) _ = json.Unmarshal([]byte(existing), &m)
} }
m["timeout"] = true for k, v := range patch {
m["timeout_minutes"] = int(timeout.Minutes()) m[k] = v
m["timeout_at"] = timeutil.FormatAPITime(now) }
m["ship_status"] = "failed"
m["fail_reason"] = fmt.Sprintf("发货超时:订单已处于 delivering 超过 %d 分钟", int(timeout.Minutes()))
raw, err := json.Marshal(m) raw, err := json.Marshal(m)
if err != nil { if err != nil {
return existing return existing
@@ -1276,6 +1290,37 @@ func buildProcessingTimeoutResultData(existing string, timeout time.Duration, no
return string(raw) 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 文本中还原游戏相关字段(仅填充当前为空的字段)。 // extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。
func extractGameFields(raw string, out *OpenOrderQuery) { func extractGameFields(raw string, out *OpenOrderQuery) {
if raw == "" || !json.Valid([]byte(raw)) { if raw == "" || !json.Valid([]byte(raw)) {
@@ -82,11 +82,6 @@ func validateOrderStatusTransition(order *model.FulfillmentOrder, next string, k
return nil return nil
} }
switch current { switch current {
case model.OrderStatusPending:
if next == model.OrderStatusPaid || next == model.OrderStatusCancelled {
return nil
}
return errors.New("订单未支付,不能发货")
case model.OrderStatusCancelled: case model.OrderStatusCancelled:
return errors.New("订单已取消,不能更新状态") return errors.New("订单已取消,不能更新状态")
case model.OrderStatusDelivered: case model.OrderStatusDelivered:
@@ -131,8 +126,6 @@ func canCancelOrder(order *model.FulfillmentOrder) error {
switch status { switch status {
case model.OrderStatusPaid, model.OrderStatusShipFailed: case model.OrderStatusPaid, model.OrderStatusShipFailed:
return nil return nil
case model.OrderStatusPending:
return errors.New("订单未支付,不能取消")
case model.OrderStatusDelivering, model.OrderStatusDelivered: case model.OrderStatusDelivering, model.OrderStatusDelivered:
return errors.New("订单已进入发货流程,不能取消") return errors.New("订单已进入发货流程,不能取消")
default: default:
+117 -2
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestFulfillmentMerchantIsolation(t *testing.T) {
db := newServiceTestDB(t) db := newServiceTestDB(t)
merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100) merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100)
@@ -580,6 +688,13 @@ func TestCanFulfill(t *testing.T) {
if !ok || reason != "" { if !ok || reason != "" {
t.Fatalf("failed paid order should be fulfillable") 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{ ok, _ = CanFulfill(&model.FulfillmentOrder{
OrderStatus: model.OrderStatusCancelled, OrderStatus: model.OrderStatusCancelled,
}) })
@@ -711,7 +826,7 @@ func TestHandleShipNotifyUpdatesOrderAndEnqueuesMerchantCallback(t *testing.T) {
if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{ if _, err := callbackSvc.CreateSubscription(merchantID, CreateCallbackInput{
Name: "发货回调", Name: "发货回调",
URL: "https://example.com/callback", URL: "https://example.com/callback",
Events: "order.fulfillment.updated", Events: "order.shipping.updated",
}, 7); err != nil { }, 7); err != nil {
t.Fatalf("create callback subscription: %v", err) 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) t.Fatalf("unexpected order after source notify: %+v", order)
} }
var delivery model.CallbackDelivery 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) t.Fatalf("query callback delivery: %v", err)
} }
if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") { if !strings.Contains(delivery.Payload, created.Order.OrderNo) || !strings.Contains(delivery.Payload, "SRC-10001") {
-1
View File
@@ -55,7 +55,6 @@
| order_status | 含义 | 是否可发货 | | order_status | 含义 | 是否可发货 |
|--------------|------|------------| |--------------|------|------------|
| `pending` | 待支付(当前无真实支付流程,仅预留) | 否 |
| `paid` | 已支付/已扣款,待发货 | 是 | | `paid` | 已支付/已扣款,待发货 | 是 |
| `delivering` | 发货中 | 否 | | `delivering` | 发货中 | 否 |
| `delivered` | 已交付 | 否 | | `delivered` | 已交付 | 否 |
-1
View File
@@ -197,7 +197,6 @@ GET /api/open/v1/orders/{order_no}
| status | 含义 | can_ship | | status | 含义 | can_ship |
|--------|------|----------| |--------|------|----------|
| `pending` | 待支付 | **false** |
| `paid` | 已支付 | **true** ← 正常可发 | | `paid` | 已支付 | **true** ← 正常可发 |
| `delivering` | 发货中 | false | | `delivering` | 发货中 | false |
| `delivered` | 已交付 | false | | `delivered` | 已交付 | false |
+2 -3
View File
@@ -18,7 +18,6 @@ export const errorCodes: ParamSpec[] = [
// 订单状态与可发货说明。 // 订单状态与可发货说明。
export const orderStatusTable: 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: 'paid', type: 'order_status', desc: '已支付/已扣款,可发货', example: 'can_ship=true' },
{ name: 'delivering', type: 'order_status', desc: '发货中', example: 'can_ship=false' }, { name: 'delivering', type: 'order_status', desc: '发货中', example: 'can_ship=false' },
{ name: 'delivered', 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', path: '/api/client/v1/orders/{order_no}/delivery-link',
title: '获取发货链接', title: '获取发货链接',
summary: '获取签名发货链接,适合直接跳转平台 Web 发货页', summary: '获取签名发货链接,适合直接跳转平台 Web 发货页',
scope: 'orders:read + fulfillment:read', scope: 'orders:read 或 shipping:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
responseExample: deliveryLinkResponseExample, responseExample: deliveryLinkResponseExample,
responseFields: [ responseFields: [
@@ -284,7 +283,7 @@ export const endpoints: EndpointSpec[] = [
path: '/api/client/v1/orders/{order_no}/delivery', path: '/api/client/v1/orders/{order_no}/delivery',
title: '查询发货数据', title: '查询发货数据',
summary: '查询发货页结构化数据,适合商户自建发货界面', summary: '查询发货页结构化数据,适合商户自建发货界面',
scope: 'orders:read + fulfillment:read', scope: 'orders:read 或 shipping:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
responseExample: deliveryOrderResponseExample, responseExample: deliveryOrderResponseExample,
responseFields: [ responseFields: [
-6
View File
@@ -161,12 +161,6 @@ export default function Dashboard() {
<Col xs={24} lg={14}> <Col xs={24} lg={14}>
<Card title="订单状态"> <Card title="订单状态">
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<StatusLine
label="待支付"
value={stats?.pending_order_count ?? 0}
total={stats?.order_count ?? 0}
color="#faad14"
/>
<StatusLine <StatusLine
label="待发货" label="待发货"
value={stats?.paid_order_count ?? 0} value={stats?.paid_order_count ?? 0}
+3 -4
View File
@@ -42,7 +42,6 @@ import type {
} from '../types' } from '../types'
const orderStatusMap: Record<string, { color: string; text: string }> = { const orderStatusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待支付' },
paid: { color: 'blue', text: '待发货' }, paid: { color: 'blue', text: '待发货' },
delivering: { color: 'cyan', text: '发货中' }, delivering: { color: 'cyan', text: '发货中' },
delivered: { color: 'green', text: '已交付' }, delivered: { color: 'green', text: '已交付' },
@@ -61,13 +60,13 @@ const scopeOptions = [
{ value: 'products:read', label: '商品读取' }, { value: 'products:read', label: '商品读取' },
{ value: 'orders:read', label: '订单读取' }, { value: 'orders:read', label: '订单读取' },
{ value: 'orders:write', label: '订单写入' }, { value: 'orders:write', label: '订单写入' },
{ value: 'fulfillment:read', label: '发货读取' }, { value: 'shipping:read', label: '发货读取' },
{ value: 'wallet:read', label: '钱包读取' }, { value: 'wallet:read', label: '钱包读取' },
] ]
const eventOptions = [ const eventOptions = [
{ value: 'order.created', label: '订单创建' }, { value: 'order.created', label: '订单创建' },
{ value: 'order.fulfillment.updated', label: '发货更新' }, { value: 'order.shipping.updated', label: '发货更新' },
{ value: 'order.cancelled', label: '订单取消' }, { value: 'order.cancelled', label: '订单取消' },
] ]
@@ -665,7 +664,7 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
<Typography.Text type="secondary"> outbox </Typography.Text> <Typography.Text type="secondary"> outbox </Typography.Text>
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => { {canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
callbackForm.resetFields() callbackForm.resetFields()
callbackForm.setFieldsValue({ events: ['order.fulfillment.updated'] }) callbackForm.setFieldsValue({ events: ['order.shipping.updated'] })
setCallbackOpen(true) setCallbackOpen(true)
}}></Button>} }}></Button>}
</Space> </Space>
+1 -1
View File
@@ -489,7 +489,7 @@ POST /orders 下单(client_order_no 幂等)
发货平台通过上游回调更新 order_status 发货平台通过上游回调更新 order_status
GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`}</pre> GET /orders/{order_no} 轮询,或接收 order.shipping.updated 回调`}</pre>
} }
/> />
</SectionWrap> </SectionWrap>
-1
View File
@@ -220,7 +220,6 @@ export interface DashboardStats {
today_sales: number today_sales: number
total_fees: number total_fees: number
today_fees: number today_fees: number
pending_order_count: number
paid_order_count: number paid_order_count: number
delivering_order_count: number delivering_order_count: number
delivered_order_count: number delivered_order_count: number