功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"affiliate_dash/internal/middleware"
|
||||
"affiliate_dash/internal/pkg/lewan"
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PlatformLewanHandler struct {
|
||||
merchantSvc *service.MerchantService
|
||||
lewanClient *lewan.Client
|
||||
}
|
||||
|
||||
func NewPlatformLewanHandler(merchantSvc *service.MerchantService, lewanClient *lewan.Client) *PlatformLewanHandler {
|
||||
return &PlatformLewanHandler{
|
||||
merchantSvc: merchantSvc,
|
||||
lewanClient: lewanClient,
|
||||
}
|
||||
}
|
||||
|
||||
// ListLewanProducts GET /api/platform/lewan/products
|
||||
// 从乐玩开放平台实时拉取商品列表供管理员浏览与关联
|
||||
func (h *PlatformLewanHandler) ListLewanProducts(c *gin.Context) {
|
||||
if h.lewanClient == nil {
|
||||
response.BadRequest(c, "乐玩开放平台尚未配置")
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
result, err := h.lewanClient.ListProducts(c.Request.Context(), page, size, keyword)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
type updateRoutingReq struct {
|
||||
RoutingMode string `json:"routing_mode" binding:"required"`
|
||||
Priority []string `json:"priority"`
|
||||
DLCGoodID string `json:"dlc_good_id"`
|
||||
LewanProductID string `json:"lewan_product_id"`
|
||||
LewanName string `json:"lewan_name"`
|
||||
DeliveryType string `json:"delivery_type"`
|
||||
}
|
||||
|
||||
// UpdateProductRouting PUT /api/platform/products/:id/routing
|
||||
// 管理员更新指定平台商品的履约渠道与优先级
|
||||
func (h *PlatformLewanHandler) UpdateProductRouting(c *gin.Context) {
|
||||
idParam := c.Param("id")
|
||||
catalogID, err := strconv.ParseUint(idParam, 10, 64)
|
||||
if err != nil || catalogID == 0 {
|
||||
response.BadRequest(c, "无效的商品ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateRoutingReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:routing_mode 必填")
|
||||
return
|
||||
}
|
||||
|
||||
actorUserID := middleware.GetUserID(c)
|
||||
routingCfg := service.ProductRoutingConfig{
|
||||
RoutingMode: req.RoutingMode,
|
||||
Priority: req.Priority,
|
||||
DLCGoodID: req.DLCGoodID,
|
||||
LewanProductID: req.LewanProductID,
|
||||
LewanName: req.LewanName,
|
||||
DeliveryType: req.DeliveryType,
|
||||
}
|
||||
|
||||
if err := h.merchantSvc.UpdateProductRouting(uint(catalogID), routingCfg, actorUserID); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
type syncLewanProductReq struct {
|
||||
LewanProductID string `json:"lewan_product_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
SKU string `json:"sku"`
|
||||
Price int64 `json:"price"`
|
||||
DeliveryType string `json:"delivery_type"`
|
||||
}
|
||||
|
||||
// SyncLewanProduct POST /api/platform/lewan/sync-catalog
|
||||
// 从乐玩拉取到的商品同步/添加到平台自营商品目录
|
||||
func (h *PlatformLewanHandler) SyncLewanProduct(c *gin.Context) {
|
||||
var req syncLewanProductReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:lewan_product_id 与 name 必填")
|
||||
return
|
||||
}
|
||||
|
||||
actorUserID := middleware.GetUserID(c)
|
||||
item, err := h.merchantSvc.SyncLewanProductToCatalog(service.SyncLewanProductInput{
|
||||
LewanProductID: req.LewanProductID,
|
||||
Name: req.Name,
|
||||
Category: req.Category,
|
||||
SKU: req.SKU,
|
||||
Price: req.Price,
|
||||
DeliveryType: req.DeliveryType,
|
||||
}, actorUserID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.WithStatus(c, http.StatusCreated, item)
|
||||
}
|
||||
Reference in New Issue
Block a user