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 {
|
||||
|
||||
Reference in New Issue
Block a user