恢复 ship_failed 可重试 UI;DLC 断点续传 queue;乐玩未绑定禁止进 delivering;Webhook 原子 nonce 与业务拒绝回 200。 Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
7.5 KiB
Go
252 lines
7.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
"affiliate_dash/internal/pkg/lewan"
|
|
"affiliate_dash/internal/pkg/response"
|
|
"affiliate_dash/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// LewanWebhookHandler 接收乐玩开放平台的 Webhook 事件推送
|
|
type LewanWebhookHandler struct {
|
|
fulfillmentSvc *service.FulfillmentService
|
|
webhookSecret string
|
|
applicationID string
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewLewanWebhookHandler(fulfillmentSvc *service.FulfillmentService, db *gorm.DB, webhookSecret, applicationID string) *LewanWebhookHandler {
|
|
return &LewanWebhookHandler{
|
|
fulfillmentSvc: fulfillmentSvc,
|
|
db: db,
|
|
webhookSecret: strings.TrimSpace(webhookSecret),
|
|
applicationID: strings.TrimSpace(applicationID),
|
|
}
|
|
}
|
|
|
|
type WebhookEventData struct {
|
|
OrderID string `json:"orderId"`
|
|
OrderNo string `json:"orderNo"`
|
|
CallerOrderID string `json:"callerOrderId"`
|
|
Status string `json:"status"` // SUCCEEDED, PARTIAL_SUCCESS, FAILED
|
|
SucceededQuantity int64 `json:"succeededQuantity"`
|
|
FailedQuantity int64 `json:"failedQuantity"`
|
|
ReleasedQuantity int64 `json:"releasedQuantity"`
|
|
PendingQuantity int64 `json:"pendingQuantity"`
|
|
FailureCodes []string `json:"failureCodes"`
|
|
ChangedUnitID string `json:"changedUnitId"`
|
|
Items json.RawMessage `json:"items"`
|
|
}
|
|
|
|
type WebhookPayload struct {
|
|
SchemaVersion string `json:"schemaVersion"`
|
|
ApplicationID string `json:"applicationId"`
|
|
EventID string `json:"eventId"`
|
|
OrderVersion *int `json:"orderVersion"`
|
|
Type string `json:"type"`
|
|
OccurredAt string `json:"occurredAt"`
|
|
Data WebhookEventData `json:"data"`
|
|
}
|
|
|
|
func (h *LewanWebhookHandler) Handle(c *gin.Context) {
|
|
appID := c.GetHeader(lewan.HeaderWebhookAppID)
|
|
eventID := c.GetHeader(lewan.HeaderWebhookEventID)
|
|
timestamp := c.GetHeader(lewan.HeaderWebhookTimestamp)
|
|
signature := c.GetHeader(lewan.HeaderWebhookSignature)
|
|
|
|
if eventID == "" || timestamp == "" || signature == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing webhook headers"})
|
|
return
|
|
}
|
|
|
|
if h.webhookSecret == "" {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "webhook secret not configured"})
|
|
return
|
|
}
|
|
|
|
// 限制请求体大小 256 KiB
|
|
rawBody, err := io.ReadAll(io.LimitReader(c.Request.Body, 256<<10))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
|
|
return
|
|
}
|
|
|
|
if !lewan.VerifyWebhook(h.webhookSecret, signature, timestamp, eventID, rawBody, 5*time.Minute) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature verification failed"})
|
|
return
|
|
}
|
|
|
|
var payload WebhookPayload
|
|
if err := json.Unmarshal(rawBody, &payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
return
|
|
}
|
|
|
|
if payload.EventID == "" {
|
|
payload.EventID = eventID
|
|
}
|
|
if payload.ApplicationID == "" {
|
|
payload.ApplicationID = appID
|
|
}
|
|
if h.applicationID != "" {
|
|
gotAppID := payload.ApplicationID
|
|
if gotAppID == "" {
|
|
gotAppID = appID
|
|
}
|
|
if gotAppID != "" && gotAppID != h.applicationID {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "application id mismatch"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 幂等:原子占位 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" {
|
|
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 {
|
|
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
|
|
}
|
|
|
|
response.OK(c, gin.H{"received": true})
|
|
}
|
|
|
|
func (h *LewanWebhookHandler) claimWebhookNonce(eventID string) (claimed bool, duplicate bool) {
|
|
if h.db == nil || eventID == "" {
|
|
return true, false
|
|
}
|
|
created := h.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SourceAPINonce{
|
|
APIKey: "lewan_webhook",
|
|
Nonce: eventID,
|
|
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
|
})
|
|
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 {
|
|
orderNo := payload.Data.CallerOrderID
|
|
switch payload.Type {
|
|
case "ORDER_COMPLETED":
|
|
status := payload.Data.Status
|
|
switch status {
|
|
case "SUCCEEDED":
|
|
_, err := h.fulfillmentSvc.HandleShipNotify(service.ShipNotifyInput{
|
|
OrderNo: orderNo,
|
|
ShipStatus: "success",
|
|
ProviderOrderNo: payload.Data.OrderNo,
|
|
RawPayload: rawBody,
|
|
})
|
|
return err
|
|
case "FAILED":
|
|
reason := strings.Join(payload.Data.FailureCodes, "; ")
|
|
if reason == "" {
|
|
reason = "乐玩发货失败"
|
|
}
|
|
_, err := h.fulfillmentSvc.HandleShipNotify(service.ShipNotifyInput{
|
|
OrderNo: orderNo,
|
|
ShipStatus: "failed",
|
|
ProviderOrderNo: payload.Data.OrderNo,
|
|
FailReason: reason,
|
|
RawPayload: rawBody,
|
|
})
|
|
return err
|
|
case "PARTIAL_SUCCESS":
|
|
if payload.Data.SucceededQuantity > 0 {
|
|
_, err := h.fulfillmentSvc.HandleShipNotify(service.ShipNotifyInput{
|
|
OrderNo: orderNo,
|
|
ShipStatus: "success",
|
|
ProviderOrderNo: payload.Data.OrderNo,
|
|
RawPayload: rawBody,
|
|
})
|
|
return err
|
|
}
|
|
reason := strings.Join(payload.Data.FailureCodes, "; ")
|
|
if reason == "" {
|
|
reason = "乐玩部分单件被停止或释放"
|
|
}
|
|
_, err := h.fulfillmentSvc.HandleShipNotify(service.ShipNotifyInput{
|
|
OrderNo: orderNo,
|
|
ShipStatus: "failed",
|
|
ProviderOrderNo: payload.Data.OrderNo,
|
|
FailReason: reason,
|
|
RawPayload: rawBody,
|
|
})
|
|
return err
|
|
}
|
|
case "BINDING_TIMEOUT", "ORDER_CANCELLED":
|
|
_, err := h.fulfillmentSvc.HandleShipNotify(service.ShipNotifyInput{
|
|
OrderNo: orderNo,
|
|
ShipStatus: "failed",
|
|
ProviderOrderNo: payload.Data.OrderNo,
|
|
FailReason: "乐玩订单超时或已取消",
|
|
RawPayload: rawBody,
|
|
})
|
|
return err
|
|
}
|
|
return nil
|
|
}
|