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

- 发货提交记录阶段推进与失败分类,失败不再清空 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
+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)
}
}
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)
}
}