加强发货提交可靠性并统一契约口径
- 发货提交记录阶段推进与失败分类,失败不再清空 result_data - 超时巡检区分已提交上游与提交中断两类卡单,避免误判 - 已提交上游的失败订单禁止自动重发,防止重复发货 - ship_attempts 仅在 claim 时计数,失败阶段只记录分类信息 - CanFulfill 对已提交上游的失败单返回不可发货 - 删除预留的 pending 订单状态,统一订单状态模型 - 契约改名:order.fulfillment.updated -> order.shipping.updated,fulfillment:read -> shipping:read - 文档修正 scope 为或关系
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user