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

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