fix(delivery): 硬化自动发货可靠性,避免双发与无限 Webhook 重试
恢复 ship_failed 可重试 UI;DLC 断点续传 queue;乐玩未绑定禁止进 delivering;Webhook 原子 nonce 与业务拒绝回 200。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LewanWebhookHandler 接收乐玩开放平台的 Webhook 事件推送
|
||||
@@ -108,49 +110,81 @@ func (h *LewanWebhookHandler) Handle(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:已处理过的 eventId 直接 200
|
||||
if h.db != nil && payload.EventID != "" {
|
||||
var existing int64
|
||||
if err := h.db.Model(&model.SourceAPINonce{}).
|
||||
Where("api_key = ? AND nonce = ?", "lewan_webhook", payload.EventID).
|
||||
Count(&existing).Error; err == nil && existing > 0 {
|
||||
// 幂等:原子占位 nonce,避免并发双处理;占位失败说明已处理/处理中
|
||||
if claimed, duplicate := h.claimWebhookNonce(payload.EventID); !claimed {
|
||||
if duplicate {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "duplicate event, ignored"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "record webhook nonce failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 特殊测试事件:验签通过后直接确认
|
||||
if payload.Type == "WEBHOOK_TEST" {
|
||||
h.recordWebhookNonce(payload.EventID)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "test event received"})
|
||||
return
|
||||
}
|
||||
|
||||
orderNo := payload.Data.CallerOrderID
|
||||
if orderNo == "" {
|
||||
// 无订单号的事件无法落地,记 nonce 防重放后确认
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "missing callerOrderId, ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.dispatchShipNotify(payload, string(rawBody)); err != nil {
|
||||
// 返回 5xx 让上游重试;尚未写入 nonce
|
||||
if isPermanentWebhookRejection(err) {
|
||||
// 业务永久拒绝:回 200 避免上游无限重试;nonce 已占位
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// 可重试错误:释放 nonce,回 5xx 让上游重试
|
||||
h.releaseWebhookNonce(payload.EventID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.recordWebhookNonce(payload.EventID)
|
||||
response.OK(c, gin.H{"received": true})
|
||||
}
|
||||
|
||||
func (h *LewanWebhookHandler) recordWebhookNonce(eventID string) {
|
||||
func (h *LewanWebhookHandler) claimWebhookNonce(eventID string) (claimed bool, duplicate bool) {
|
||||
if h.db == nil || eventID == "" {
|
||||
return
|
||||
return true, false
|
||||
}
|
||||
_ = h.db.Create(&model.SourceAPINonce{
|
||||
created := h.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SourceAPINonce{
|
||||
APIKey: "lewan_webhook",
|
||||
Nonce: eventID,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
}).Error
|
||||
})
|
||||
if created.Error != nil {
|
||||
return false, false
|
||||
}
|
||||
if created.RowsAffected == 0 {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
func (h *LewanWebhookHandler) releaseWebhookNonce(eventID string) {
|
||||
if h.db == nil || eventID == "" {
|
||||
return
|
||||
}
|
||||
_ = h.db.Where("api_key = ? AND nonce = ?", "lewan_webhook", eventID).
|
||||
Delete(&model.SourceAPINonce{}).Error
|
||||
}
|
||||
|
||||
func isPermanentWebhookRejection(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, service.ErrShipNotifyRejected) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "订单已取消") ||
|
||||
strings.Contains(msg, "订单已交付,拒绝失败") ||
|
||||
strings.Contains(msg, "当前状态不允许")
|
||||
}
|
||||
|
||||
func (h *LewanWebhookHandler) dispatchShipNotify(payload WebhookPayload, rawBody string) error {
|
||||
|
||||
@@ -548,8 +548,21 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
|
||||
activeProvider := providers[0]
|
||||
|
||||
submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID)
|
||||
if submitRes != nil && len(submitRes.PatchResultData) > 0 {
|
||||
stage := deliveryStageCreateOrder
|
||||
if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" {
|
||||
stage = v
|
||||
}
|
||||
_ = s.recordDeliverySubmissionStage(claimed, stage, submitRes.PatchResultData)
|
||||
}
|
||||
if err != nil {
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, err.Error())
|
||||
failStage := deliveryStageCreateOrder
|
||||
if submitRes != nil {
|
||||
if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" {
|
||||
failStage = v
|
||||
}
|
||||
}
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, failStage, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -563,6 +576,8 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
|
||||
ResultData: resultData,
|
||||
})
|
||||
if err != nil {
|
||||
// 上游已有身份时先固化 result_data,避免重试双发
|
||||
_ = s.recordDeliverySubmissionStage(claimed, deliveryStageSubmitted, submitRes.PatchResultData)
|
||||
return nil, err
|
||||
}
|
||||
return &DeliverySubmitResult{
|
||||
@@ -599,7 +614,10 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
|
||||
}
|
||||
now := time.Now()
|
||||
stage := resultDataString(order.ResultData, "provider_order_stage")
|
||||
if deliverySubmissionInProgress(stage) && !deliverySubmissionStale(&order, now) {
|
||||
// ship_failed 可立即重试;仅对仍处于 paid 等提交中态拦截并发双发
|
||||
if orderStatus != model.OrderStatusShipFailed &&
|
||||
deliverySubmissionInProgress(stage) &&
|
||||
!deliverySubmissionStale(&order, now) {
|
||||
updated = order
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -451,6 +451,60 @@ func TestDeliveryMerchantApiBindAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryClaimAllowsShipFailedWithQueueStage(t *testing.T) {
|
||||
recentStartedAt := time.Now().Add(-time.Minute)
|
||||
merchantID, deliverySvc, order := createDeliveryOrderWithSubmitStage(t, "delivery-claim-failed-queue", deliveryStageQueueCreated, recentStartedAt, 1)
|
||||
if err := deliverySvc.fulfillment.db.Model(&model.FulfillmentOrder{}).
|
||||
Where("id = ?", order.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"order_status": model.OrderStatusShipFailed,
|
||||
"failure_reason": "补充发货账号失败",
|
||||
"result_data": mergeResultData(order.ResultData, map[string]interface{}{
|
||||
"queue_order_id": "queue-resume-1",
|
||||
}),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed ship_failed: %v", err)
|
||||
}
|
||||
|
||||
claimed, claimedNow, err := deliverySvc.claimDeliverySubmission(merchantID, 88, order.OrderNo)
|
||||
if err != nil {
|
||||
t.Fatalf("claim ship_failed with queue stage: %v", err)
|
||||
}
|
||||
if !claimedNow {
|
||||
t.Fatalf("ship_failed should allow immediate reclaim even with queue_created stage")
|
||||
}
|
||||
if got := resultDataString(claimed.ResultData, "queue_order_id"); got != "queue-resume-1" {
|
||||
t.Fatalf("reclaim should preserve queue_order_id, got %q", got)
|
||||
}
|
||||
if got := resultDataNumber(claimed.ResultData, "ship_attempts"); got != 2 {
|
||||
t.Fatalf("reclaim should count a new attempt, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeFulfillmentResultDataAvoidsDoubleMarshal(t *testing.T) {
|
||||
rawJSON := `{"provider":"dlc","queue_order_id":"q1"}`
|
||||
encoded, err := encodeFulfillmentResultData(rawJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("encode string json: %v", err)
|
||||
}
|
||||
if encoded != rawJSON {
|
||||
t.Fatalf("json string should pass through, got %q", encoded)
|
||||
}
|
||||
|
||||
encodedMap, err := encodeFulfillmentResultData(map[string]interface{}{"provider": "dlc"})
|
||||
if err != nil {
|
||||
t.Fatalf("encode map: %v", err)
|
||||
}
|
||||
if !json.Valid([]byte(encodedMap)) || !strings.Contains(encodedMap, `"provider":"dlc"`) {
|
||||
t.Fatalf("map should marshal once, got %q", encodedMap)
|
||||
}
|
||||
|
||||
// 若对已是 JSON 字符串再 Marshal 会变成带转义的字符串;这里确保不会
|
||||
if strings.HasPrefix(encoded, "\"") {
|
||||
t.Fatalf("should not double-encode json object string: %q", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryClaimBlocksRecentInProgressStage(t *testing.T) {
|
||||
recentStartedAt := time.Now().Add(-time.Minute)
|
||||
merchantID, deliverySvc, order := createDeliveryOrderWithSubmitStage(t, "delivery-claim-recent", deliveryStageClaimed, recentStartedAt, 1)
|
||||
|
||||
@@ -399,11 +399,11 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
|
||||
var resultData string
|
||||
hasResultData := in.ResultData != nil
|
||||
if in.ResultData != nil {
|
||||
raw, err := json.Marshal(in.ResultData)
|
||||
encoded, err := encodeFulfillmentResultData(in.ResultData)
|
||||
if err != nil {
|
||||
return nil, errors.New("发货结果无法序列化")
|
||||
}
|
||||
resultData = string(raw)
|
||||
resultData = encoded
|
||||
}
|
||||
var out model.FulfillmentOrder
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -62,6 +63,40 @@ func mergeResultData(existing string, patch map[string]interface{}) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// encodeFulfillmentResultData 写入 result_data:已是 JSON 对象/数组字符串时不再二次 Marshal,避免双重编码。
|
||||
func encodeFulfillmentResultData(v interface{}) (string, error) {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(t)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if json.Valid([]byte(trimmed)) && (strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
|
||||
return trimmed, nil
|
||||
}
|
||||
raw, err := json.Marshal(t)
|
||||
return string(raw), err
|
||||
case []byte:
|
||||
trimmed := bytes.TrimSpace(t)
|
||||
if len(trimmed) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if json.Valid(trimmed) && (trimmed[0] == '{' || trimmed[0] == '[') {
|
||||
return string(trimmed), nil
|
||||
}
|
||||
raw, err := json.Marshal(t)
|
||||
return string(raw), err
|
||||
default:
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
}
|
||||
|
||||
func resultDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw == "" {
|
||||
|
||||
@@ -33,6 +33,17 @@ type ShipNotifyResult struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrShipNotifyRejected 表示业务永久拒绝(如已取消/已交付拒失败),Webhook 应回 200 避免无限重试。
|
||||
var ErrShipNotifyRejected = errors.New("ship notify rejected")
|
||||
|
||||
// wrapShipNotifyRejected 将永久拒绝包装为可识别错误。
|
||||
func wrapShipNotifyRejected(err error) error {
|
||||
if err == nil {
|
||||
return ErrShipNotifyRejected
|
||||
}
|
||||
return errors.Join(ErrShipNotifyRejected, err)
|
||||
}
|
||||
|
||||
// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。
|
||||
func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) {
|
||||
in.ShipStatus = strings.TrimSpace(in.ShipStatus)
|
||||
@@ -97,7 +108,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = errors.New("订单已取消,无法更新发货状态")
|
||||
rejectionErr = wrapShipNotifyRejected(errors.New("订单已取消,无法更新发货状态"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -112,7 +123,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = err
|
||||
rejectionErr = wrapShipNotifyRejected(err)
|
||||
return nil
|
||||
}
|
||||
updates["delivered_at"] = shippedAt
|
||||
@@ -130,7 +141,7 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = err
|
||||
rejectionErr = wrapShipNotifyRejected(err)
|
||||
return nil
|
||||
}
|
||||
updates["failure_reason"] = in.FailReason
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1155,3 +1156,32 @@ func TestBuildShipNotifyResultDataSuccessRemovesFailureReason(t *testing.T) {
|
||||
t.Fatalf("success result data should keep ship_status, got %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleShipNotifyRejectedOnCancelledReturnsTypedError(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-notify-cancel", 1000, 2, 100)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 41,
|
||||
ClientOrderNo: "client-notify-cancel",
|
||||
SKU: product.SKU,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.Model(&model.FulfillmentOrder{}).Where("id = ?", created.Order.ID).Updates(map[string]interface{}{
|
||||
"order_status": model.OrderStatusCancelled,
|
||||
"cancelled_at": now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("cancel order: %v", err)
|
||||
}
|
||||
_, err = svc.HandleShipNotify(ShipNotifyInput{
|
||||
OrderNo: created.Order.OrderNo,
|
||||
ShipStatus: "success",
|
||||
})
|
||||
if !errors.Is(err, ErrShipNotifyRejected) {
|
||||
t.Fatalf("expected ErrShipNotifyRejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,18 +197,30 @@ func (p *DLCProvider) Submit(ctx context.Context, order *model.FulfillmentOrder,
|
||||
goodsNo := sku
|
||||
goodsName := order.ProductName
|
||||
|
||||
queueOrderID, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建发货队列失败: %w", err)
|
||||
queueOrderID := resultDataString(order.ResultData, "queue_order_id")
|
||||
partial := map[string]interface{}{
|
||||
"provider": ProviderDLC,
|
||||
}
|
||||
if queueOrderID == "" {
|
||||
createdID, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建发货队列失败: %w", err)
|
||||
}
|
||||
queueOrderID = createdID
|
||||
partial["queue_order_id"] = queueOrderID
|
||||
partial["provider_order_stage"] = deliveryStageQueueCreated
|
||||
} else {
|
||||
partial["queue_order_id"] = queueOrderID
|
||||
partial["provider_order_stage"] = deliveryStageQueueCreated
|
||||
}
|
||||
|
||||
if err := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil {
|
||||
return nil, fmt.Errorf("补充发货账号失败: %w", err)
|
||||
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("补充发货账号失败: %w", err)
|
||||
}
|
||||
|
||||
upstreamOrder, err := p.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建上游正式订单失败: %w", err)
|
||||
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("创建上游正式订单失败: %w", err)
|
||||
}
|
||||
|
||||
providerOrderNo := firstNonEmpty(
|
||||
|
||||
@@ -303,6 +303,10 @@ func (p *LewanProvider) Submit(ctx context.Context, order *model.FulfillmentOrde
|
||||
return nil, errors.New("未获取到乐玩订单")
|
||||
}
|
||||
|
||||
if lewanOrder.Status == "WAITING_BINDING" {
|
||||
return nil, errors.New("角色尚未绑定完成,请先完成扫码绑定后再提交发货")
|
||||
}
|
||||
|
||||
providerOrderNo := lewanOrder.OrderNo
|
||||
if providerOrderNo == "" {
|
||||
providerOrderNo = lewanOrder.ID
|
||||
|
||||
@@ -485,8 +485,8 @@ export default function Delivery() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 状态 3:发货失败 */}
|
||||
{order.status === 'ship_failed' && (
|
||||
{/* 状态 3:发货失败且不可自动重试 */}
|
||||
{order.status === 'ship_failed' && !order.can_ship && (
|
||||
<section style={panelStyle}>
|
||||
<Result
|
||||
status="error"
|
||||
@@ -501,10 +501,18 @@ export default function Delivery() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 状态 4:待绑定扫码 */}
|
||||
{order.status === 'paid' && (
|
||||
{/* 状态 4:待绑定扫码(含未提交上游的 ship_failed 可重试) */}
|
||||
{(order.status === 'paid' || (order.status === 'ship_failed' && order.can_ship)) && (
|
||||
<section style={panelStyle}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{order.status === 'ship_failed' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="上次发货未成功,可重新绑定并提交"
|
||||
description={order.ship_fail_reason || order.cannot_ship_reason || undefined}
|
||||
/>
|
||||
)}
|
||||
<Steps
|
||||
current={step}
|
||||
items={[
|
||||
|
||||
Reference in New Issue
Block a user