加强发货提交可靠性并统一契约口径
- 发货提交记录阶段推进与失败分类,失败不再清空 result_data - 超时巡检区分已提交上游与提交中断两类卡单,避免误判 - 已提交上游的失败订单禁止自动重发,防止重复发货 - ship_attempts 仅在 claim 时计数,失败阶段只记录分类信息 - CanFulfill 对已提交上游的失败单返回不可发货 - 删除预留的 pending 订单状态,统一订单状态模型 - 契约改名:order.fulfillment.updated -> order.shipping.updated,fulfillment:read -> shipping:read - 文档修正 scope 为或关系
This commit is contained in:
@@ -128,7 +128,6 @@ make docker-down # 停止并移除
|
||||
|
||||
| 订单状态 | 含义 | 是否可发货 |
|
||||
|----------|------|------------|
|
||||
| `pending` | 待支付(当前无真实支付流程,仅预留) | 否 |
|
||||
| `paid` | 已支付/已扣款,待发货 | 是 |
|
||||
| `delivering` | 发货中 | 否 |
|
||||
| `delivered` | 已交付 | 否 |
|
||||
|
||||
@@ -34,7 +34,6 @@ const (
|
||||
ProductStatusActive = "active"
|
||||
ProductStatusInactive = "inactive"
|
||||
|
||||
OrderStatusPending = "pending" // 待支付(当前无真实支付流程,仅预留)
|
||||
OrderStatusPaid = "paid" // 已扣款,待发货
|
||||
OrderStatusDelivering = "delivering" // 发货中
|
||||
OrderStatusDelivered = "delivered" // 已交付
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
|
||||
| order_status | 含义 | 是否可发货 |
|
||||
|--------------|------|------------|
|
||||
| `pending` | 待支付(当前无真实支付流程,仅预留) | 否 |
|
||||
| `paid` | 已支付/已扣款,待发货 | 是 |
|
||||
| `delivering` | 发货中 | 否 |
|
||||
| `delivered` | 已交付 | 否 |
|
||||
|
||||
@@ -197,7 +197,6 @@ GET /api/open/v1/orders/{order_no}
|
||||
|
||||
| status | 含义 | can_ship |
|
||||
|--------|------|----------|
|
||||
| `pending` | 待支付 | **false** |
|
||||
| `paid` | 已支付 | **true** ← 正常可发 |
|
||||
| `delivering` | 发货中 | false |
|
||||
| `delivered` | 已交付 | false |
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -161,12 +161,6 @@ export default function Dashboard() {
|
||||
<Col xs={24} lg={14}>
|
||||
<Card title="订单状态">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<StatusLine
|
||||
label="待支付"
|
||||
value={stats?.pending_order_count ?? 0}
|
||||
total={stats?.order_count ?? 0}
|
||||
color="#faad14"
|
||||
/>
|
||||
<StatusLine
|
||||
label="待发货"
|
||||
value={stats?.paid_order_count ?? 0}
|
||||
|
||||
@@ -42,7 +42,6 @@ import type {
|
||||
} from '../types'
|
||||
|
||||
const orderStatusMap: Record<string, { color: string; text: string }> = {
|
||||
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
|
||||
<Typography.Text type="secondary">回调事件通过 outbox 持久化发送。</Typography.Text>
|
||||
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
callbackForm.resetFields()
|
||||
callbackForm.setFieldsValue({ events: ['order.fulfillment.updated'] })
|
||||
callbackForm.setFieldsValue({ events: ['order.shipping.updated'] })
|
||||
setCallbackOpen(true)
|
||||
}}>新增回调</Button>}
|
||||
</Space>
|
||||
|
||||
@@ -489,7 +489,7 @@ POST /orders 下单(client_order_no 幂等)
|
||||
↓
|
||||
发货平台通过上游回调更新 order_status
|
||||
↓
|
||||
GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`}</pre>
|
||||
GET /orders/{order_no} 轮询,或接收 order.shipping.updated 回调`}</pre>
|
||||
}
|
||||
/>
|
||||
</SectionWrap>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user