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

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