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 }