Files
affiliate_dash/backend/internal/handler/lewan_webhook.go
T
yml2213andCursor f7f48d0feb 功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:48:03 +08:00

218 lines
6.5 KiB
Go

package handler
import (
"encoding/json"
"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"
)
// 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
}
}
// 幂等:已处理过的 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 {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "duplicate event, ignored"})
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 == "" {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "missing callerOrderId, ignored"})
return
}
if err := h.dispatchShipNotify(payload, string(rawBody)); err != nil {
// 返回 5xx 让上游重试;尚未写入 nonce
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) {
if h.db == nil || eventID == "" {
return
}
_ = h.db.Create(&model.SourceAPINonce{
APIKey: "lewan_webhook",
Nonce: eventID,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
}).Error
}
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
}