功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -54,6 +54,13 @@ type Config struct {
|
||||
// CORSAllowedOrigins 跨域允许的来源(逗号分隔);为空时仅放行常见本地开发端口。
|
||||
// 生产前后端同域(Caddy 统一入口)时无需配置;仅在前后端分离部署时设置。
|
||||
CORSAllowedOrigins []string
|
||||
|
||||
// 乐玩开放平台配置
|
||||
LewanBaseURL string
|
||||
LewanApplicationID string
|
||||
LewanAccessKey string
|
||||
LewanSecretKey string
|
||||
LewanWebhookSecret string
|
||||
}
|
||||
|
||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||
@@ -86,6 +93,11 @@ func Load() *Config {
|
||||
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
|
||||
UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
|
||||
CORSAllowedOrigins: getEnvList("CORS_ALLOWED_ORIGINS", nil),
|
||||
LewanBaseURL: getEnv("LEWAN_BASE_URL", "https://shop.cloudtentacles.com"),
|
||||
LewanApplicationID: getEnv("LEWAN_APPLICATION_ID", ""),
|
||||
LewanAccessKey: getEnv("LEWAN_ACCESS_KEY", ""),
|
||||
LewanSecretKey: getEnv("LEWAN_SECRET_KEY", ""),
|
||||
LewanWebhookSecret: getEnv("LEWAN_WEBHOOK_SECRET", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package lewan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
ApplicationID string
|
||||
WebhookSecret string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg Config, httpClient *http.Client) *Client {
|
||||
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://shop.cloudtentacles.com"
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetHTTPClient(client *http.Client) {
|
||||
if client != nil {
|
||||
c.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
// 统一包装响应
|
||||
type ResponseEnvelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
RequestId string `json:"requestId"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message interface{} `json:"message,omitempty"` // string or []string
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Product 代表乐玩平台商品
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
CategoryId string `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
ThumbnailUrl string `json:"thumbnailUrl"`
|
||||
Stock int64 `json:"stock"`
|
||||
RoleDeliveryLimit *int `json:"roleDeliveryLimit"`
|
||||
DeliveryType string `json:"deliveryType"` // LINK_BINDING, EMAIL_BINDING
|
||||
EffectiveUnitPrice string `json:"effectiveUnitPrice"`
|
||||
AccessMode string `json:"accessMode"` // PURCHASE, VIEW
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ProductVersion string `json:"productVersion"`
|
||||
}
|
||||
|
||||
type ProductListResult struct {
|
||||
Items []Product `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// OrderItem 下单商品项
|
||||
type OrderItem struct {
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// CreateOrderReq 下单请求
|
||||
type CreateOrderReq struct {
|
||||
CallerOrderID string `json:"callerOrderId"`
|
||||
RoleID string `json:"roleId"`
|
||||
Items []OrderItem `json:"items"`
|
||||
}
|
||||
|
||||
// BindingAction 绑定动作快照
|
||||
type BindingAction struct {
|
||||
BindingRequired bool `json:"bindingRequired"`
|
||||
BindingType string `json:"bindingType"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
BindingDeadline *string `json:"bindingDeadline"`
|
||||
BindingUrl string `json:"bindingUrl"`
|
||||
BindingUrlIssuedAt *string `json:"bindingUrlIssuedAt"`
|
||||
BindingUrlExpiresAt *string `json:"bindingUrlExpiresAt"`
|
||||
BindingUrlExpired bool `json:"bindingUrlExpired"`
|
||||
BindingActionEndpoint *string `json:"bindingActionEndpoint"`
|
||||
EmailCodeSent bool `json:"emailCodeSent"`
|
||||
AllocationInProgress bool `json:"allocationInProgress"`
|
||||
BindingActionInProgress bool `json:"bindingActionInProgress"`
|
||||
}
|
||||
|
||||
// LewanOrder 乐玩订单
|
||||
type LewanOrder struct {
|
||||
ID string `json:"id"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
CallerOrderID string `json:"callerOrderId"`
|
||||
OrderVersion int `json:"orderVersion"`
|
||||
RoleID string `json:"roleId"`
|
||||
CategoryId string `json:"categoryId"`
|
||||
DeliveryType string `json:"deliveryType"`
|
||||
OrderedAmount string `json:"orderedAmount"`
|
||||
SettledAmount string `json:"settledAmount"`
|
||||
ReleasedAmount string `json:"releasedAmount"`
|
||||
HeldAmount string `json:"heldAmount"`
|
||||
FeeAmount string `json:"feeAmount"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
SucceededQuantity int64 `json:"succeededQuantity"`
|
||||
FailedQuantity int64 `json:"failedQuantity"`
|
||||
ReleasedQuantity int64 `json:"releasedQuantity"`
|
||||
PendingQuantity int64 `json:"pendingQuantity"`
|
||||
FailureCodes []string `json:"failureCodes"`
|
||||
Status string `json:"status"` // WAITING_BINDING, QUEUED, PROCESSING, RECONCILING, SUCCEEDED, PARTIAL_SUCCESS, FAILED, CANCELLED
|
||||
BindingDeadline *string `json:"bindingDeadline"`
|
||||
BindingAction *BindingAction `json:"bindingAction,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrderResult 下单响应
|
||||
type CreateOrderResult struct {
|
||||
Order *LewanOrder `json:"order"`
|
||||
BindingAction *BindingAction `json:"bindingAction"`
|
||||
IdempotentReplay bool `json:"idempotentReplay"`
|
||||
}
|
||||
|
||||
// BindingResult 绑定刷新响应
|
||||
type BindingInfo struct {
|
||||
Status string `json:"status"`
|
||||
Verified bool `json:"verified"`
|
||||
Matched bool `json:"matched"`
|
||||
RoleID string `json:"roleId"`
|
||||
RoleName string `json:"roleName"`
|
||||
ServerName string `json:"serverName"`
|
||||
}
|
||||
|
||||
type RefreshBindingResult struct {
|
||||
Order *LewanOrder `json:"order"`
|
||||
Binding *BindingInfo `json:"binding"`
|
||||
}
|
||||
|
||||
type WalletBalance struct {
|
||||
AvailableBalance string `json:"availableBalance"`
|
||||
FrozenBalance string `json:"frozenBalance"`
|
||||
TotalBalance string `json:"totalBalance"`
|
||||
}
|
||||
|
||||
// ListProducts 查询当前可见商品列表
|
||||
func (c *Client) ListProducts(ctx context.Context, page, pageSize int, keyword string) (*ProductListResult, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
query := fmt.Sprintf("page=%d&pageSize=%d", page, pageSize)
|
||||
if keyword != "" {
|
||||
query += "&keyword=" + RFC3986Escape(keyword)
|
||||
}
|
||||
|
||||
var out ProductListResult
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/products", query, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetProduct 查询单个商品详情
|
||||
func (c *Client) GetProduct(ctx context.Context, id string) (*Product, error) {
|
||||
var out Product
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/products/"+id, "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CreateOrder 幂等创建聚合订单
|
||||
func (c *Client) CreateOrder(ctx context.Context, req CreateOrderReq) (*CreateOrderResult, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out CreateOrderResult
|
||||
if err := c.do(ctx, http.MethodPost, "/openapi/v1/orders", "", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetOrder 按订单 ID 查单
|
||||
func (c *Client) GetOrder(ctx context.Context, id string) (*LewanOrder, error) {
|
||||
var out LewanOrder
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/orders/"+id, "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetOrderByCallerOrder 按调用方订单号精确查单(用于超时补偿)
|
||||
func (c *Client) GetOrderByCallerOrder(ctx context.Context, callerOrderID string) (*LewanOrder, error) {
|
||||
query := "callerOrderId=" + RFC3986Escape(callerOrderID)
|
||||
var out LewanOrder
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/orders/by-caller-order-id", query, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RestoreBindingAction 重新进入时显式获取或恢复原订单绑定动作
|
||||
func (c *Client) RestoreBindingAction(ctx context.Context, orderID string) (*CreateOrderResult, error) {
|
||||
var out CreateOrderResult
|
||||
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/binding/action", orderID), "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RefreshBinding LINK 用户绑定后刷新角色信息;匹配成功即进入发货
|
||||
func (c *Client) RefreshBinding(ctx context.Context, orderID string) (*RefreshBindingResult, error) {
|
||||
var out RefreshBindingResult
|
||||
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/binding/refresh", orderID), "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// CancelOrder 取消整单(仅在未越过渠道提交边界时有效)
|
||||
func (c *Client) CancelOrder(ctx context.Context, orderID string) (*LewanOrder, error) {
|
||||
var out LewanOrder
|
||||
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/cancel", orderID), "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetWallet 查询钱包余额
|
||||
func (c *Client) GetWallet(ctx context.Context) (*WalletBalance, error) {
|
||||
var out WalletBalance
|
||||
if err := c.do(ctx, http.MethodGet, "/openapi/v1/wallets", "", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path, query string, body []byte, out interface{}) error {
|
||||
if c.cfg.AccessKey == "" || c.cfg.SecretKey == "" {
|
||||
return errors.New("乐玩开放平台 AccessKey 或 SecretKey 未配置")
|
||||
}
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := GenerateNonce()
|
||||
bodySHA := SHA256Hex(body)
|
||||
|
||||
signingStr := BuildSigningString(method, path, query, bodySHA, ts, nonce)
|
||||
signature := SignRequest(c.cfg.SecretKey, signingStr)
|
||||
|
||||
reqURL := c.cfg.BaseURL + path
|
||||
if query != "" {
|
||||
reqURL += "?" + query
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if len(body) > 0 {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set(HeaderAccessKey, c.cfg.AccessKey)
|
||||
req.Header.Set(HeaderTimestamp, ts)
|
||||
req.Header.Set(HeaderNonce, nonce)
|
||||
req.Header.Set(HeaderSignature, signature)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求乐玩平台网络异常: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<21)) // limit 2MB
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取乐玩响应失败: %w", err)
|
||||
}
|
||||
|
||||
var envelope ResponseEnvelope
|
||||
if err := json.Unmarshal(respBytes, &envelope); err != nil {
|
||||
return fmt.Errorf("解析乐玩响应失败 (HTTP %d): %s", resp.StatusCode, string(respBytes))
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
errMsg := ""
|
||||
switch v := envelope.Message.(type) {
|
||||
case string:
|
||||
errMsg = v
|
||||
case []interface{}:
|
||||
var parts []string
|
||||
for _, item := range v {
|
||||
parts = append(parts, fmt.Sprint(item))
|
||||
}
|
||||
errMsg = strings.Join(parts, "; ")
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = envelope.Error
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if envelope.Code != "" {
|
||||
return fmt.Errorf("[%s] %s", envelope.Code, errMsg)
|
||||
}
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
|
||||
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("解析乐玩业务数据失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package lewan
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EmptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
HeaderAccessKey = "X-OpenAPI-Access-Key"
|
||||
HeaderTimestamp = "X-OpenAPI-Timestamp"
|
||||
HeaderNonce = "X-OpenAPI-Nonce"
|
||||
HeaderSignature = "X-OpenAPI-Signature"
|
||||
HeaderRequestID = "X-Request-Id"
|
||||
|
||||
HeaderWebhookAppID = "X-OpenAPI-Application-Id"
|
||||
HeaderWebhookEventID = "X-OpenAPI-Event-Id"
|
||||
HeaderWebhookSchemaVer = "X-OpenAPI-Schema-Version"
|
||||
HeaderWebhookTimestamp = "X-OpenAPI-Timestamp"
|
||||
HeaderWebhookSignature = "X-OpenAPI-Signature"
|
||||
)
|
||||
|
||||
// RFC3986Escape encodes a string according to RFC 3986.
|
||||
// Space is encoded as %20 (not +).
|
||||
func RFC3986Escape(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if isRFC3986Unreserved(c) {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isRFC3986Unreserved(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') ||
|
||||
(b >= 'A' && b <= 'Z') ||
|
||||
(b >= '0' && b <= '9') ||
|
||||
b == '-' || b == '_' || b == '.' || b == '~'
|
||||
}
|
||||
|
||||
type queryPair struct {
|
||||
encodedKey string
|
||||
encodedVal string
|
||||
}
|
||||
|
||||
// NormalizeQuery parses a raw query string and normalizes it according to Lewan OpenAPI V1 rules:
|
||||
// RFC 3986 encoded key and value, sorted by encoded key then encoded value, joined by '&'.
|
||||
func NormalizeQuery(rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
var pairs []queryPair
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
var rawKey, rawVal string
|
||||
idx := strings.IndexByte(part, '=')
|
||||
if idx >= 0 {
|
||||
rawKey = part[:idx]
|
||||
rawVal = part[idx+1:]
|
||||
} else {
|
||||
rawKey = part
|
||||
rawVal = ""
|
||||
}
|
||||
// Unescape first if percent-encoded, to canonicalize
|
||||
decKey, err := url.QueryUnescape(strings.ReplaceAll(rawKey, "+", "%2B"))
|
||||
if err != nil {
|
||||
decKey = rawKey
|
||||
}
|
||||
decVal, err := url.QueryUnescape(strings.ReplaceAll(rawVal, "+", "%2B"))
|
||||
if err != nil {
|
||||
decVal = rawVal
|
||||
}
|
||||
|
||||
pairs = append(pairs, queryPair{
|
||||
encodedKey: RFC3986Escape(decKey),
|
||||
encodedVal: RFC3986Escape(decVal),
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
if pairs[i].encodedKey != pairs[j].encodedKey {
|
||||
return pairs[i].encodedKey < pairs[j].encodedKey
|
||||
}
|
||||
return pairs[i].encodedVal < pairs[j].encodedVal
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
for i, p := range pairs {
|
||||
if i > 0 {
|
||||
sb.WriteByte('&')
|
||||
}
|
||||
sb.WriteString(p.encodedKey)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(p.encodedVal)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// SHA256Hex returns the hex-encoded SHA-256 of the byte slice.
|
||||
func SHA256Hex(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return EmptyBodySHA256
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// BuildSigningString constructs the 6-line string to be signed:
|
||||
// 1. HTTP_METHOD (UPPERCASE)
|
||||
// 2. PATH
|
||||
// 3. RFC3986_QUERY
|
||||
// 4. SHA256_HEX_OF_RAW_BODY
|
||||
// 5. UNIX_TIMESTAMP_SECONDS
|
||||
// 6. NONCE
|
||||
func BuildSigningString(method, path, query, bodySHA256, timestamp, nonce string) string {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
path = strings.TrimSpace(path)
|
||||
normQuery := NormalizeQuery(query)
|
||||
if bodySHA256 == "" {
|
||||
bodySHA256 = EmptyBodySHA256
|
||||
}
|
||||
timestamp = strings.TrimSpace(timestamp)
|
||||
nonce = strings.TrimSpace(nonce)
|
||||
|
||||
return strings.Join([]string{
|
||||
method,
|
||||
path,
|
||||
normQuery,
|
||||
bodySHA256,
|
||||
timestamp,
|
||||
nonce,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// SignRequest computes the hex HMAC-SHA256 signature for a request.
|
||||
func SignRequest(secretKey, signingString string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secretKey))
|
||||
mac.Write([]byte(signingString))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// GenerateNonce creates a random hex nonce of given byte length (e.g. 16 bytes = 32 hex chars).
|
||||
func GenerateNonce() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return "n" + hex.EncodeToString(b)[1:] // ensure leading char is letter/number
|
||||
}
|
||||
|
||||
// SignWebhook computes the v1= signature for an incoming/outgoing webhook.
|
||||
func SignWebhook(secret, timestamp, eventID string, rawBody []byte) string {
|
||||
prefix := timestamp + "." + eventID + "."
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(prefix))
|
||||
mac.Write(rawBody)
|
||||
return "v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifyWebhook validates a webhook signature with constant-time comparison and timestamp check.
|
||||
func VerifyWebhook(secret, signature, timestamp, eventID string, rawBody []byte, allowedSkew time.Duration) bool {
|
||||
if secret == "" || signature == "" || timestamp == "" || eventID == "" {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if allowedSkew > 0 {
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if time.Duration(diff)*time.Second > allowedSkew {
|
||||
return false
|
||||
}
|
||||
}
|
||||
expected := SignWebhook(secret, timestamp, eventID, rawBody)
|
||||
actual := signature
|
||||
if !strings.HasPrefix(actual, "v1=") {
|
||||
actual = "v1=" + actual
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package lewan
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOfficialVectors(t *testing.T) {
|
||||
// 固定密钥与时间戳
|
||||
accessKey := "ak_test_fixed_20260905"
|
||||
secretKey := "sk_test_fixed_20260905"
|
||||
timestamp := "1788566400"
|
||||
_ = accessKey
|
||||
|
||||
// Vector 1: GET 查询
|
||||
t.Run("Vector1_GET_Query", func(t *testing.T) {
|
||||
method := "GET"
|
||||
path := "/openapi/v1/products"
|
||||
rawQuery := "pageSize=20&page=1"
|
||||
nonce := "nonce_get_20260905_fixed"
|
||||
bodySHA256 := EmptyBodySHA256
|
||||
expectedSig := "54ea478585e61230be670d664745c81ffb4907b87c8592bf082c9cc894232adf"
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, bodySHA256, timestamp, nonce)
|
||||
expectedSigningStr := "GET\n/openapi/v1/products\npage=1&pageSize=20\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n1788566400\nnonce_get_20260905_fixed"
|
||||
if signingStr != expectedSigningStr {
|
||||
t.Fatalf("signing string mismatch:\ngot:\n%s\nwant:\n%s", signingStr, expectedSigningStr)
|
||||
}
|
||||
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 2: POST JSON
|
||||
t.Run("Vector2_POST_JSON", func(t *testing.T) {
|
||||
method := "POST"
|
||||
path := "/openapi/v1/orders"
|
||||
rawQuery := ""
|
||||
body := []byte(`{"callerOrderId":"DOC-20260905-001","roleId":"1148711973","items":[{"productId":"00000000-0000-4000-8000-000000000101","quantity":2}]}`)
|
||||
nonce := "nonce_post_20260905_fixed"
|
||||
expectedBodySHA := "146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032"
|
||||
expectedSig := "1e1d3df46b885e76b1e992585215e488c29315e7207e62df4aa26b0124d04968"
|
||||
|
||||
bodySHA := SHA256Hex(body)
|
||||
if bodySHA != expectedBodySHA {
|
||||
t.Fatalf("body SHA256 mismatch:\ngot: %s\nwant: %s", bodySHA, expectedBodySHA)
|
||||
}
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, bodySHA, timestamp, nonce)
|
||||
expectedSigningStr := "POST\n/openapi/v1/orders\n\n146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032\n1788566400\nnonce_post_20260905_fixed"
|
||||
if signingStr != expectedSigningStr {
|
||||
t.Fatalf("signing string mismatch:\ngot:\n%s\nwant:\n%s", signingStr, expectedSigningStr)
|
||||
}
|
||||
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 3: 重复查询参数
|
||||
t.Run("Vector3_Duplicate_Query", func(t *testing.T) {
|
||||
method := "GET"
|
||||
path := "/openapi/v1/orders"
|
||||
rawQuery := "status=SUCCEEDED&keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED"
|
||||
nonce := "nonce_repeat_20260905_fixed"
|
||||
expectedSig := "c1c2be18372e3ac89e8042f3477fff8287126e31777a18a593c0cce7028626d8"
|
||||
|
||||
normQuery := NormalizeQuery(rawQuery)
|
||||
expectedNormQuery := "keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED&status=SUCCEEDED"
|
||||
if normQuery != expectedNormQuery {
|
||||
t.Fatalf("normalized query mismatch:\ngot: %s\nwant: %s", normQuery, expectedNormQuery)
|
||||
}
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, EmptyBodySHA256, timestamp, nonce)
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 4: POST 空请求体
|
||||
t.Run("Vector4_POST_Empty_Body", func(t *testing.T) {
|
||||
method := "POST"
|
||||
path := "/openapi/v1/orders/00000000-0000-4000-8000-000000000201/account/release"
|
||||
rawQuery := ""
|
||||
nonce := "nonce_empty_20260905_fixed"
|
||||
expectedSig := "167f6d7c116fc7e51b0d2418e65a0705f08076940025f2c013adbd8f27648908"
|
||||
|
||||
signingStr := BuildSigningString(method, path, rawQuery, EmptyBodySHA256, timestamp, nonce)
|
||||
sig := SignRequest(secretKey, signingStr)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
})
|
||||
|
||||
// Vector 5: Webhook 固定向量
|
||||
t.Run("Vector5_Webhook", func(t *testing.T) {
|
||||
webhookSecret := "whsec_test_fixed_20260905"
|
||||
eventID := "00000000-0000-4000-8000-000000000302"
|
||||
rawBody := []byte(`{"applicationId":"00000000-0000-4000-8000-000000000301","data":{"callerOrderId":"DOC-20260905-001","failedQuantity":1,"failureCodes":["SUPPLIER_REJECTED"],"orderId":"00000000-0000-4000-8000-000000000303","pendingQuantity":0,"releasedQuantity":0,"status":"PARTIAL_SUCCESS","succeededQuantity":1},"eventId":"00000000-0000-4000-8000-000000000302","occurredAt":"2026-09-05T00:00:00.000Z","orderVersion":7,"schemaVersion":"1.0","type":"ORDER_COMPLETED"}`)
|
||||
expectedSig := "v1=ee3968ef467a4f9c19c0a33e7af1bb3118fab8d02c5ebf9cbd8001cc34ebe6be"
|
||||
|
||||
sig := SignWebhook(webhookSecret, timestamp, eventID, rawBody)
|
||||
if sig != expectedSig {
|
||||
t.Fatalf("webhook signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
|
||||
}
|
||||
|
||||
// 测试验证函数(不限时限)
|
||||
if !VerifyWebhook(webhookSecret, sig, timestamp, eventID, rawBody, 0) {
|
||||
t.Fatalf("VerifyWebhook should succeed")
|
||||
}
|
||||
// 测试错误 secret
|
||||
if VerifyWebhook("wrong_secret", sig, timestamp, eventID, rawBody, 0) {
|
||||
t.Fatalf("VerifyWebhook should fail with wrong secret")
|
||||
}
|
||||
// 测试时间窗口超限
|
||||
if VerifyWebhook(webhookSecret, sig, timestamp, eventID, rawBody, 5*time.Minute) {
|
||||
t.Fatalf("VerifyWebhook should fail for expired timestamp")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -30,10 +30,13 @@ type Handlers struct {
|
||||
OpenAPIKey string
|
||||
OpenAPISecret string
|
||||
OpenSignSkew int64
|
||||
OpenAPIDebug bool
|
||||
UploadDir string
|
||||
OpenAPIDebug bool
|
||||
UploadDir string
|
||||
// CORSAllowedOrigins 跨域来源白名单;为空时使用本地开发默认来源。
|
||||
CORSAllowedOrigins []string
|
||||
|
||||
LewanWebhook *handler.LewanWebhookHandler
|
||||
PlatformLewan *handler.PlatformLewanHandler
|
||||
}
|
||||
|
||||
// defaultCORSOrigins 未配置 CORS_ALLOWED_ORIGINS 时的本地开发默认来源。
|
||||
@@ -98,6 +101,11 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
{
|
||||
api.POST("/auth/login", h.Auth.Login)
|
||||
|
||||
// 乐玩开放平台 Webhook 回调
|
||||
if h.LewanWebhook != nil {
|
||||
api.POST("/upstream/lewan/webhook", h.LewanWebhook.Handle)
|
||||
}
|
||||
|
||||
// 用户侧公开发货页接口:商户把发货链接给用户,用户只填写游戏 UID。
|
||||
delivery := api.Group("/delivery/v1")
|
||||
{
|
||||
@@ -215,6 +223,13 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet)
|
||||
admin.GET("/platform/recharge/applications", h.Recharge.AdminListApplications)
|
||||
admin.POST("/platform/recharge/applications/:id/review", h.Recharge.AdminReviewApplication)
|
||||
|
||||
// 乐玩平台商品拉取、同步与履约路由配置
|
||||
if h.PlatformLewan != nil {
|
||||
admin.GET("/platform/lewan/products", h.PlatformLewan.ListLewanProducts)
|
||||
admin.POST("/platform/lewan/sync-catalog", h.PlatformLewan.SyncLewanProduct)
|
||||
admin.PUT("/platform/products/:id/routing", h.PlatformLewan.UpdateProductRouting)
|
||||
}
|
||||
}
|
||||
|
||||
auth.POST("/upload", h.Recharge.Upload)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -57,6 +58,7 @@ type DeliveryService struct {
|
||||
linkSecret string
|
||||
linkTTL time.Duration
|
||||
httpClient *http.Client
|
||||
router *FulfillmentRouter
|
||||
}
|
||||
|
||||
func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, linkBaseURL, linkSecret string, linkTTLMinutes int) *DeliveryService {
|
||||
@@ -77,6 +79,9 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, li
|
||||
if linkTTLMinutes < 0 {
|
||||
linkTTLMinutes = 0
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
dlc := NewDLCProvider(bffBaseURL, channel, client)
|
||||
router := NewFulfillmentRouter(dlc)
|
||||
return &DeliveryService{
|
||||
fulfillment: fulfillment,
|
||||
bffBaseURL: bffBaseURL,
|
||||
@@ -84,16 +89,90 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, li
|
||||
linkBaseURL: linkBaseURL,
|
||||
linkSecret: linkSecret,
|
||||
linkTTL: time.Duration(linkTTLMinutes) * time.Minute,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
httpClient: client,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeliveryService) SetHTTPClient(client *http.Client) {
|
||||
if client != nil {
|
||||
s.httpClient = client
|
||||
if dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider); ok && dlc != nil {
|
||||
dlc.httpClient = client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeliveryService) SetRouter(router *FulfillmentRouter) {
|
||||
if router != nil {
|
||||
s.router = router
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeliveryService) RegisterProvider(provider FulfillmentProvider) {
|
||||
if s.router == nil {
|
||||
s.router = NewFulfillmentRouter(provider)
|
||||
} else if provider != nil {
|
||||
s.router.providers[provider.Name()] = provider
|
||||
}
|
||||
}
|
||||
|
||||
// CancelUpstream 取消订单时同步取消已锁定/已建单的上游履约渠道。
|
||||
func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error {
|
||||
if order == nil || s.router == nil {
|
||||
return nil
|
||||
}
|
||||
if name := resultDataString(order.ResultData, "provider"); name != "" {
|
||||
if p := s.router.GetProvider(name); p != nil {
|
||||
return p.Cancel(context.Background(), order)
|
||||
}
|
||||
}
|
||||
if resultDataString(order.ResultData, "lewan_order_id") != "" {
|
||||
if p := s.router.GetProvider(ProviderLewan); p != nil {
|
||||
return p.Cancel(context.Background(), order)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。
|
||||
func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error {
|
||||
if order == nil || len(patch) == 0 {
|
||||
return nil
|
||||
}
|
||||
resultData := mergeResultData(order.ResultData, patch)
|
||||
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("result_data", resultData).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
order.ResultData = resultData
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) clearSelectedProvider(order *model.FulfillmentOrder) {
|
||||
if order == nil || strings.TrimSpace(order.ResultData) == "" {
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(order.ResultData), &raw); err != nil {
|
||||
return
|
||||
}
|
||||
if _, ok := raw["provider"]; !ok {
|
||||
return
|
||||
}
|
||||
delete(raw, "provider")
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resultData := string(b)
|
||||
_ = s.fulfillment.db.Model(&model.FulfillmentOrder{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("result_data", resultData).Error
|
||||
order.ResultData = resultData
|
||||
}
|
||||
|
||||
type DeliveryOrderInfo struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
@@ -208,31 +287,27 @@ func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID strin
|
||||
if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.accountBound(bindUUID, goodID)
|
||||
providers, _ := s.router.ResolveProviders(order)
|
||||
if len(providers) == 0 {
|
||||
return nil, errors.New("未配置发货渠道")
|
||||
}
|
||||
p := providers[0]
|
||||
res, err := p.CheckBind(context.Background(), order, bindUUID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "尚未绑定") || strings.Contains(err.Error(), "未绑定") {
|
||||
return &DeliveryBindResult{BindUUID: bindUUID, Bound: false, Message: "尚未绑定,请先完成扫码绑定"}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
boundAccount := stringFromMap(account, "game_account")
|
||||
result := &DeliveryBindResult{
|
||||
BindUUID: bindUUID,
|
||||
Bound: true,
|
||||
GameAccount: boundAccount,
|
||||
RoleName: stringFromMap(account, "game_account_role_name"),
|
||||
GameChannel: gameChannelText(account),
|
||||
Message: "绑定成功",
|
||||
}
|
||||
// 下单时 data 透传的预期账号:绑定账号与预期不一致时提示,供自建页提前拦截。
|
||||
if expected := requestDataString(order.RequestData, "game_account"); expected != "" {
|
||||
result.ExpectedGameAccount = expected
|
||||
if boundAccount != expected {
|
||||
result.Mismatch = true
|
||||
result.Message = "绑定成功,但绑定账号与下单预期账号不一致,请确认"
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return &DeliveryBindResult{
|
||||
BindUUID: res.BindUUID,
|
||||
BindURL: res.BindURL,
|
||||
QRURL: res.QRURL,
|
||||
Bound: res.Bound,
|
||||
GameAccount: res.GameAccount,
|
||||
RoleName: res.RoleName,
|
||||
GameChannel: res.GameChannel,
|
||||
ExpectedGameAccount: res.ExpectedGameAccount,
|
||||
Mismatch: res.Mismatch,
|
||||
Message: res.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *DeliveryLinkAuth, merchantID uint) (*DeliveryBindResult, error) {
|
||||
@@ -258,29 +333,72 @@ func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *Deliv
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.bindGood(gameAccount, goodID)
|
||||
|
||||
providers, _ := s.router.ResolveProviders(order)
|
||||
if len(providers) == 0 {
|
||||
return nil, errors.New("未配置发货渠道")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var bindRes *ProviderBindResult
|
||||
var usedProvider FulfillmentProvider
|
||||
for _, p := range providers {
|
||||
res, err := p.Bind(context.Background(), order, gameAccount)
|
||||
if err == nil && res != nil {
|
||||
bindRes = res
|
||||
usedProvider = p
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if bindRes == nil {
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, errors.New("发货渠道未就绪")
|
||||
}
|
||||
|
||||
// 记录选定渠道与上游单号(如 lewan_order_id),不提前写入 submit claimed stage
|
||||
if usedProvider != nil {
|
||||
patch := map[string]interface{}{
|
||||
"provider": usedProvider.Name(),
|
||||
}
|
||||
for k, v := range bindRes.PatchResultData {
|
||||
patch[k] = v
|
||||
}
|
||||
_ = s.persistOrderResultPatch(order, patch)
|
||||
}
|
||||
|
||||
return &DeliveryBindResult{
|
||||
BindUUID: bindRes.BindUUID,
|
||||
BindURL: bindRes.BindURL,
|
||||
QRURL: bindRes.QRURL,
|
||||
Bound: bindRes.Bound,
|
||||
GameAccount: bindRes.GameAccount,
|
||||
RoleName: bindRes.RoleName,
|
||||
GameChannel: bindRes.GameChannel,
|
||||
ExpectedGameAccount: bindRes.ExpectedGameAccount,
|
||||
Mismatch: bindRes.Mismatch,
|
||||
Message: bindRes.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) {
|
||||
var out struct {
|
||||
BindUUID string `json:"bind_uuid"`
|
||||
URL string `json:"url"`
|
||||
dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider)
|
||||
if !ok || dlc == nil {
|
||||
return nil, errors.New("DLC 渠道未就绪")
|
||||
}
|
||||
if err := s.signProxy("/public/games/bind-account", "POST", map[string]interface{}{
|
||||
"game_account": gameAccount,
|
||||
"gameAccount": gameAccount,
|
||||
"goodId": goodID,
|
||||
"good_id": goodID,
|
||||
}, &out); err != nil {
|
||||
res, err := dlc.Bind(context.Background(), &model.FulfillmentOrder{
|
||||
ProductSKU: "",
|
||||
}, gameAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.BindUUID == "" || out.URL == "" {
|
||||
return nil, errors.New("绑定服务未返回绑定二维码")
|
||||
}
|
||||
return &DeliveryBindResult{
|
||||
BindUUID: out.BindUUID,
|
||||
BindURL: out.URL,
|
||||
QRURL: "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(out.URL),
|
||||
BindUUID: res.BindUUID,
|
||||
BindURL: res.BindURL,
|
||||
QRURL: res.QRURL,
|
||||
Bound: res.Bound,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -321,7 +439,8 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
|
||||
if orderStatus == model.OrderStatusDelivering ||
|
||||
orderStatus == model.OrderStatusDelivered ||
|
||||
strings.TrimSpace(order.ProviderOrderNo) != "" ||
|
||||
strings.Contains(order.ResultData, "\"queue_order_id\"") {
|
||||
strings.Contains(order.ResultData, "\"queue_order_id\"") ||
|
||||
strings.Contains(order.ResultData, "\"lewan_order_id\"") {
|
||||
if existing := buildExistingDeliverySubmitResult(order); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -347,74 +466,27 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
|
||||
}
|
||||
return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询")
|
||||
}
|
||||
boundAccount, err := s.accountBound(bindUUID, goodID)
|
||||
if err != nil {
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindVerify, err.Error())
|
||||
return nil, err
|
||||
|
||||
providers, _ := s.router.ResolveProviders(claimed)
|
||||
if len(providers) == 0 {
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, "未配置发货渠道")
|
||||
return nil, errors.New("未配置发货渠道")
|
||||
}
|
||||
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
|
||||
err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindMismatch, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
goodsNo := ""
|
||||
goodsName := ""
|
||||
if info != nil && info.Product != nil {
|
||||
goodsNo = info.Product.SKU
|
||||
goodsName = info.Product.Name
|
||||
} else if openOrder != nil && openOrder.Product != nil {
|
||||
goodsNo = openOrder.Product.SKU
|
||||
goodsName = openOrder.Product.Name
|
||||
}
|
||||
queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo, goodsNo)
|
||||
if err != nil {
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateQueue, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
// 建 queue 后只持久化阶段信息,不提前改成 delivering。
|
||||
// 上游创建正式订单前会回查开放接口,此时订单必须仍然 can_ship=true。
|
||||
if err := s.recordDeliverySubmissionStage(claimed, deliveryStageQueueCreated, map[string]interface{}{
|
||||
"queue_order_id": queueOrderID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil {
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStagePatchQueue, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
|
||||
activeProvider := providers[0]
|
||||
|
||||
submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID)
|
||||
if err != nil {
|
||||
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
providerOrderNo := firstNonEmpty(
|
||||
stringFromMap(upstreamOrder, "order_no"),
|
||||
stringFromMap(upstreamOrder, "_id"),
|
||||
stringFromMap(upstreamOrder, "id"),
|
||||
stringFromMap(upstreamOrder, "order_sn"),
|
||||
queueOrderID,
|
||||
)
|
||||
resultData := mergeResultData(claimed.ResultData, map[string]interface{}{
|
||||
"provider_order_stage": deliveryStageSubmitted,
|
||||
"queue_order_id": queueOrderID,
|
||||
"provider_order_no": providerOrderNo,
|
||||
"game_uid": gameAccount,
|
||||
"role_name": stringFromMap(boundAccount, "game_account_role_name"),
|
||||
"game_channel": gameChannelText(boundAccount),
|
||||
"delivery": upstreamOrder,
|
||||
})
|
||||
nextStatus := model.OrderStatusDelivering
|
||||
message := "已提交发货,等待发货结果回传"
|
||||
if upstreamDeliverySucceeded(upstreamOrder) {
|
||||
nextStatus = model.OrderStatusDelivered
|
||||
message = "发货已完成"
|
||||
}
|
||||
|
||||
resultData := mergeResultData(claimed.ResultData, submitRes.PatchResultData)
|
||||
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
|
||||
MerchantID: order.MerchantID,
|
||||
APIClientID: apiClientID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: nextStatus,
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
Status: submitRes.NextStatus,
|
||||
ProviderOrderNo: submitRes.ProviderOrderNo,
|
||||
ResultData: resultData,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -423,10 +495,10 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
|
||||
return &DeliverySubmitResult{
|
||||
OrderNo: updated.OrderNo,
|
||||
Status: normalizeOrderStatus(updated),
|
||||
Message: message,
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
GameAccount: boundAccount,
|
||||
UpstreamOrder: upstreamOrder,
|
||||
Message: submitRes.Message,
|
||||
ProviderOrderNo: submitRes.ProviderOrderNo,
|
||||
GameAccount: submitRes.GameAccount,
|
||||
UpstreamOrder: submitRes.UpstreamOrder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -436,6 +508,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
|
||||
err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("MerchantProduct").
|
||||
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -470,7 +543,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
|
||||
return err
|
||||
}
|
||||
claimedNow = true
|
||||
if err := tx.First(&updated, order.ID).Error; err != nil {
|
||||
if err := tx.Preload("MerchantProduct").First(&updated, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
apiClientIDPtr := optionalUint(apiClientID)
|
||||
@@ -582,27 +655,58 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
|
||||
if openOrder.Product == nil || openOrder.Product.SKU == "" {
|
||||
canShip = false
|
||||
reason = "订单缺少商品 SKU"
|
||||
} else if goodID == "" {
|
||||
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
|
||||
if goodID == "" {
|
||||
canShip = false
|
||||
reason = "未找到对应的商品发货配置"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
deliveryProd *DeliveryProduct
|
||||
goodData map[string]interface{}
|
||||
)
|
||||
|
||||
providers, _ := s.router.ResolveProviders(order)
|
||||
if len(providers) > 0 {
|
||||
locked := resultDataString(order.ResultData, "provider") != ""
|
||||
matchedState, lastReason := s.tryPrepareProviders(order, openOrder, providers)
|
||||
// 已锁定渠道不可发时,清除锁定并按完整优先级重新选择
|
||||
if matchedState == nil && locked {
|
||||
s.clearSelectedProvider(order)
|
||||
providers, _ = s.router.ResolveProviders(order)
|
||||
matchedState, lastReason = s.tryPrepareProviders(order, openOrder, providers)
|
||||
}
|
||||
if matchedState != nil {
|
||||
deliveryProd = matchedState.Product
|
||||
goodData = matchedState.Good
|
||||
if matchedState.Provider != "" {
|
||||
_ = s.persistOrderResultPatch(order, map[string]interface{}{
|
||||
"provider": matchedState.Provider,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
canShip = false
|
||||
if reason == "" || reason == "订单暂不可发货" {
|
||||
reason = lastReason
|
||||
if reason == "" {
|
||||
reason = "未找到适用的履约渠道配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if goodID != "" {
|
||||
goodData, _ = s.goodsDetail(goodID)
|
||||
}
|
||||
|
||||
if deliveryProd == nil && openOrder.Product != nil {
|
||||
deliveryProd = buildDeliveryProduct(openOrder.Product, goodData)
|
||||
}
|
||||
|
||||
if requireCanShip && !canShip {
|
||||
return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
|
||||
}
|
||||
var good map[string]interface{}
|
||||
if goodID != "" {
|
||||
good, _ = s.goodsDetail(goodID)
|
||||
}
|
||||
product := buildDeliveryProduct(openOrder.Product, good)
|
||||
|
||||
info := &DeliveryOrderInfo{
|
||||
OrderNo: openOrder.OrderNo,
|
||||
Status: openOrder.Status,
|
||||
CanShip: canShip,
|
||||
CannotShipReason: reason,
|
||||
Product: product,
|
||||
Product: deliveryProd,
|
||||
BuyerName: openOrder.BuyerName,
|
||||
Amount: openOrder.Amount,
|
||||
CreatedAt: openOrder.CreatedAt,
|
||||
@@ -613,11 +717,32 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
|
||||
RoleName: openOrder.RoleName,
|
||||
PayScore: openOrder.PayScore,
|
||||
Data: requestDataMap(order.RequestData),
|
||||
Good: good,
|
||||
Good: goodData,
|
||||
}
|
||||
return info, order, goodID, nil
|
||||
}
|
||||
|
||||
// tryPrepareProviders 按优先级尝试 Prepare,返回第一个 CanShip 的渠道状态。
|
||||
func (s *DeliveryService) tryPrepareProviders(order *model.FulfillmentOrder, openOrder *OpenOrderQuery, providers []FulfillmentProvider) (*DeliveryOrderState, string) {
|
||||
lastReason := ""
|
||||
for _, p := range providers {
|
||||
state, err := p.PrepareDelivery(context.Background(), order, openOrder)
|
||||
if err != nil || state == nil {
|
||||
continue
|
||||
}
|
||||
if state.Provider == "" {
|
||||
state.Provider = p.Name()
|
||||
}
|
||||
if state.CanShip {
|
||||
return state, ""
|
||||
}
|
||||
if state.CannotShipReason != "" {
|
||||
lastReason = state.CannotShipReason
|
||||
}
|
||||
}
|
||||
return nil, lastReason
|
||||
}
|
||||
|
||||
func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
|
||||
orderNo = strings.TrimSpace(orderNo)
|
||||
if orderNo == "" {
|
||||
@@ -761,52 +886,7 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth
|
||||
return nil, nil, "", err
|
||||
}
|
||||
}
|
||||
canShip, reason := CanFulfill(order)
|
||||
if !openOrder.CanShip {
|
||||
reason = openOrder.CannotShipReason
|
||||
canShip = false
|
||||
}
|
||||
if !canShip && reason == "" {
|
||||
reason = "订单暂不可发货"
|
||||
}
|
||||
goodID := ""
|
||||
if openOrder.Product == nil || openOrder.Product.SKU == "" {
|
||||
canShip = false
|
||||
reason = "订单缺少商品 SKU"
|
||||
} else {
|
||||
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
|
||||
if goodID == "" {
|
||||
canShip = false
|
||||
reason = "未找到对应的商品发货配置"
|
||||
}
|
||||
}
|
||||
if requireCanShip && !canShip {
|
||||
return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
|
||||
}
|
||||
var good map[string]interface{}
|
||||
if goodID != "" {
|
||||
good, _ = s.goodsDetail(goodID)
|
||||
}
|
||||
product := buildDeliveryProduct(openOrder.Product, good)
|
||||
info := &DeliveryOrderInfo{
|
||||
OrderNo: openOrder.OrderNo,
|
||||
Status: openOrder.Status,
|
||||
CanShip: canShip,
|
||||
CannotShipReason: reason,
|
||||
Product: product,
|
||||
BuyerName: openOrder.BuyerName,
|
||||
Amount: openOrder.Amount,
|
||||
CreatedAt: openOrder.CreatedAt,
|
||||
ShippedAt: openOrder.ShippedAt,
|
||||
ShipFailReason: openOrder.ShipFailReason,
|
||||
GameChannel: openOrder.GameChannel,
|
||||
GameUID: openOrder.GameUID,
|
||||
RoleName: openOrder.RoleName,
|
||||
PayScore: openOrder.PayScore,
|
||||
Data: requestDataMap(order.RequestData),
|
||||
Good: good,
|
||||
}
|
||||
return info, order, goodID, nil
|
||||
return s.buildDeliveryState(openOrder, order, "", requireCanShip)
|
||||
}
|
||||
|
||||
func deliveryGoodID(channel, sku string) string {
|
||||
|
||||
@@ -16,14 +16,20 @@ import (
|
||||
)
|
||||
|
||||
type FulfillmentService struct {
|
||||
db *gorm.DB
|
||||
callbacks *CallbackService
|
||||
db *gorm.DB
|
||||
callbacks *CallbackService
|
||||
upstreamCancel func(order *model.FulfillmentOrder) error
|
||||
}
|
||||
|
||||
func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService {
|
||||
return &FulfillmentService{db: db, callbacks: callbacks}
|
||||
}
|
||||
|
||||
// SetUpstreamCancel 注册取消订单时同步取消上游履约单的钩子(如乐玩 CancelOrder)。
|
||||
func (s *FulfillmentService) SetUpstreamCancel(fn func(order *model.FulfillmentOrder) error) {
|
||||
s.upstreamCancel = fn
|
||||
}
|
||||
|
||||
type CreateFulfillmentOrderInput struct {
|
||||
MerchantID uint
|
||||
APIClientID uint
|
||||
@@ -511,6 +517,12 @@ func (s *FulfillmentService) cancelOrder(in CancelOrderInput) (*model.Fulfillmen
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusShipFailed && deliverySubmittedUpstream(&order) {
|
||||
return errors.New("订单已提交上游发货,为避免重复发货无法取消,请先在上游确认订单状态")
|
||||
}
|
||||
// 先取消上游履约单,避免本地退款后上游仍占库存/资金
|
||||
if s.upstreamCancel != nil {
|
||||
if err := s.upstreamCancel(&order); err != nil {
|
||||
return errors.New("取消上游履约订单失败: " + err.Error())
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{"order_status": model.OrderStatusCancelled}
|
||||
updates["failure_reason"] = in.Reason
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
@@ -11,16 +13,18 @@ import (
|
||||
)
|
||||
|
||||
type ProductCatalogItem struct {
|
||||
ID uint `json:"id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
SKU string `json:"sku"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Category string `json:"category"`
|
||||
PriceAmount int64 `json:"price_amount"`
|
||||
CostAmount int64 `json:"cost_amount"`
|
||||
Currency string `json:"currency"`
|
||||
Stock int64 `json:"stock"`
|
||||
Status string `json:"status"`
|
||||
ID uint `json:"id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
SKU string `json:"sku"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Category string `json:"category"`
|
||||
PriceAmount int64 `json:"price_amount"`
|
||||
CostAmount int64 `json:"cost_amount"`
|
||||
Currency string `json:"currency"`
|
||||
Stock int64 `json:"stock"`
|
||||
Status string `json:"status"`
|
||||
FulfillmentConfig string `json:"fulfillment_config,omitempty"`
|
||||
Routing ProductRoutingConfig `json:"routing"`
|
||||
}
|
||||
|
||||
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
|
||||
@@ -42,17 +46,20 @@ func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) {
|
||||
if p.Product != nil {
|
||||
category = p.Product.Category
|
||||
}
|
||||
routing := ParseProductRoutingConfig(p.FulfillmentConfig, p.SKU)
|
||||
items = append(items, ProductCatalogItem{
|
||||
ID: p.ID,
|
||||
ProductID: p.ProductID,
|
||||
SKU: p.SKU,
|
||||
DisplayName: p.DisplayName,
|
||||
Category: category,
|
||||
PriceAmount: p.PriceAmount,
|
||||
CostAmount: p.CostAmount,
|
||||
Currency: p.Currency,
|
||||
Stock: p.Stock,
|
||||
Status: p.Status,
|
||||
ID: p.ID,
|
||||
ProductID: p.ProductID,
|
||||
SKU: p.SKU,
|
||||
DisplayName: p.DisplayName,
|
||||
Category: category,
|
||||
PriceAmount: p.PriceAmount,
|
||||
CostAmount: p.CostAmount,
|
||||
Currency: p.Currency,
|
||||
Stock: p.Stock,
|
||||
Status: p.Status,
|
||||
FulfillmentConfig: p.FulfillmentConfig,
|
||||
Routing: routing,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -260,3 +267,192 @@ func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product,
|
||||
}
|
||||
return product, nil
|
||||
}
|
||||
|
||||
// UpdateProductRouting 更新平台目录商品的履约渠道与优先级配置
|
||||
func (s *MerchantService) UpdateProductRouting(catalogID uint, routing ProductRoutingConfig, actorUserID uint) error {
|
||||
switch routing.RoutingMode {
|
||||
case RoutingModePriority, RoutingModeLewan, RoutingModeDLC:
|
||||
default:
|
||||
return errors.New("routing_mode 仅支持 priority / lewan / dlc")
|
||||
}
|
||||
if routing.RoutingMode == RoutingModePriority {
|
||||
if len(routing.Priority) == 0 {
|
||||
routing.Priority = []string{ProviderLewan, ProviderDLC}
|
||||
}
|
||||
for _, name := range routing.Priority {
|
||||
if name != ProviderLewan && name != ProviderDLC {
|
||||
return errors.New("priority 仅支持 lewan / dlc")
|
||||
}
|
||||
}
|
||||
}
|
||||
if routing.RoutingMode == RoutingModeLewan || routing.RoutingMode == RoutingModePriority {
|
||||
if strings.TrimSpace(routing.LewanProductID) == "" {
|
||||
return errors.New("乐玩渠道需配置 lewan_product_id")
|
||||
}
|
||||
}
|
||||
if routing.DeliveryType != "" && routing.DeliveryType != "LINK_BINDING" && routing.DeliveryType != "EMAIL_BINDING" {
|
||||
return errors.New("delivery_type 仅支持 LINK_BINDING / EMAIL_BINDING")
|
||||
}
|
||||
|
||||
configBytes, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configStr := string(configBytes)
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var item model.MerchantProduct
|
||||
if err := tx.Preload("Product").Where("id = ?", catalogID).First(&item).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("目录商品不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新自营模板
|
||||
if err := tx.Model(&model.MerchantProduct{}).Where("id = ?", item.ID).Update("fulfillment_config", configStr).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同步更新所有商户同 SKU 商品的履约配置,保证全平台生效
|
||||
if item.SKU != "" {
|
||||
_ = tx.Model(&model.MerchantProduct{}).Where("sku = ?", item.SKU).Update("fulfillment_config", configStr).Error
|
||||
}
|
||||
|
||||
return writeAudit(tx, &item.MerchantID, &actorUserID, nil, "catalog.routing.update", "merchant_product", fmt.Sprint(catalogID), map[string]string{
|
||||
"sku": item.SKU,
|
||||
"routing_mode": routing.RoutingMode,
|
||||
"priority": strings.Join(routing.Priority, ","),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type SyncLewanProductInput struct {
|
||||
LewanProductID string `json:"lewan_product_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
SKU string `json:"sku"`
|
||||
Price int64 `json:"price"`
|
||||
DeliveryType string `json:"delivery_type"`
|
||||
}
|
||||
|
||||
// SyncLewanProductToCatalog 导入或关联乐玩商品至平台商品目录
|
||||
func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, actorUserID uint) (*ProductCatalogItem, error) {
|
||||
if in.LewanProductID == "" {
|
||||
return nil, errors.New("乐玩商品ID不能为空")
|
||||
}
|
||||
if in.Name == "" {
|
||||
return nil, errors.New("商品名称不能为空")
|
||||
}
|
||||
|
||||
var res *ProductCatalogItem
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var selfMerchant model.Merchant
|
||||
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
|
||||
return errors.New("自营商户未初始化")
|
||||
}
|
||||
|
||||
sku := strings.TrimSpace(in.SKU)
|
||||
if sku == "" {
|
||||
// 自动根据乐玩 UUID 派生 SKU
|
||||
cleanID := strings.ReplaceAll(in.LewanProductID, "-", "")
|
||||
if len(cleanID) > 12 {
|
||||
cleanID = cleanID[:12]
|
||||
}
|
||||
sku = "lewan_" + cleanID
|
||||
}
|
||||
|
||||
// 检查 Product
|
||||
var product model.Product
|
||||
if err := tx.Where("code = ?", sku).First(&product).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
product = model.Product{
|
||||
Code: sku,
|
||||
Name: in.Name,
|
||||
Category: in.Category,
|
||||
Status: model.ProductStatusActive,
|
||||
}
|
||||
if err := tx.Create(&product).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
routing := ProductRoutingConfig{
|
||||
RoutingMode: RoutingModeLewan,
|
||||
Priority: []string{ProviderLewan, ProviderDLC},
|
||||
LewanProductID: in.LewanProductID,
|
||||
LewanName: in.Name,
|
||||
DeliveryType: in.DeliveryType,
|
||||
}
|
||||
if routing.DeliveryType == "" {
|
||||
routing.DeliveryType = "LINK_BINDING"
|
||||
}
|
||||
if routing.DeliveryType != "LINK_BINDING" && routing.DeliveryType != "EMAIL_BINDING" {
|
||||
return errors.New("delivery_type 仅支持 LINK_BINDING / EMAIL_BINDING")
|
||||
}
|
||||
if builtinDLC := deliveryGoodID("dlc", sku); builtinDLC != "" {
|
||||
routing.DLCGoodID = builtinDLC
|
||||
routing.RoutingMode = RoutingModePriority
|
||||
}
|
||||
configBytes, _ := json.Marshal(routing)
|
||||
configStr := string(configBytes)
|
||||
|
||||
// 检查自营商户商品
|
||||
var mp model.MerchantProduct
|
||||
if err := tx.Where("merchant_id = ? AND sku = ?", selfMerchant.ID, sku).First(&mp).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
price := in.Price
|
||||
if price <= 0 {
|
||||
price = 1000
|
||||
}
|
||||
mp = model.MerchantProduct{
|
||||
MerchantID: selfMerchant.ID,
|
||||
ProductID: product.ID,
|
||||
SKU: sku,
|
||||
DisplayName: in.Name,
|
||||
PriceAmount: price,
|
||||
CostAmount: price,
|
||||
Currency: "POINT",
|
||||
Stock: -1,
|
||||
Status: model.ProductStatusActive,
|
||||
FulfillmentConfig: configStr,
|
||||
}
|
||||
if err := tx.Create(&mp).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 更新现有配置
|
||||
_ = tx.Model(&mp).Update("fulfillment_config", configStr).Error
|
||||
}
|
||||
|
||||
res = &ProductCatalogItem{
|
||||
ID: mp.ID,
|
||||
ProductID: product.ID,
|
||||
SKU: sku,
|
||||
DisplayName: mp.DisplayName,
|
||||
Category: product.Category,
|
||||
PriceAmount: mp.PriceAmount,
|
||||
CostAmount: mp.CostAmount,
|
||||
Currency: mp.Currency,
|
||||
Stock: mp.Stock,
|
||||
Status: mp.Status,
|
||||
FulfillmentConfig: configStr,
|
||||
Routing: routing,
|
||||
}
|
||||
|
||||
return writeAudit(tx, &selfMerchant.ID, &actorUserID, nil, "catalog.lewan.sync", "merchant_product", fmt.Sprint(mp.ID), map[string]string{
|
||||
"lewan_product_id": in.LewanProductID,
|
||||
"sku": sku,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderDLC = "dlc"
|
||||
ProviderLewan = "lewan"
|
||||
|
||||
RoutingModePriority = "priority"
|
||||
RoutingModeLewan = "lewan"
|
||||
RoutingModeDLC = "dlc"
|
||||
)
|
||||
|
||||
// ProductRoutingConfig 商品履约配置
|
||||
type ProductRoutingConfig struct {
|
||||
RoutingMode string `json:"routing_mode"` // priority | lewan | dlc
|
||||
Priority []string `json:"priority"` // ["lewan", "dlc"] 或 ["dlc", "lewan"]
|
||||
DLCGoodID string `json:"dlc_good_id"` // 第一平台 GoodID
|
||||
LewanProductID string `json:"lewan_product_id"` // 乐玩商品 UUID
|
||||
LewanName string `json:"lewan_name,omitempty"`
|
||||
DeliveryType string `json:"delivery_type,omitempty"` // LINK_BINDING / EMAIL_BINDING
|
||||
}
|
||||
|
||||
// ParseProductRoutingConfig 解析商品履约配置,若未配置则根据 SKU 默认走 DLC 兜底。
|
||||
func ParseProductRoutingConfig(fulfillmentConfig string, sku string) ProductRoutingConfig {
|
||||
var cfg ProductRoutingConfig
|
||||
if strings.TrimSpace(fulfillmentConfig) != "" {
|
||||
_ = json.Unmarshal([]byte(fulfillmentConfig), &cfg)
|
||||
}
|
||||
|
||||
// 兼容老配置或单 provider 字段
|
||||
if cfg.RoutingMode == "" {
|
||||
var legacy struct {
|
||||
Provider string `json:"provider"`
|
||||
LewanProductID string `json:"lewan_product_id"`
|
||||
DLCGoodID string `json:"dlc_good_id"`
|
||||
}
|
||||
if strings.TrimSpace(fulfillmentConfig) != "" {
|
||||
_ = json.Unmarshal([]byte(fulfillmentConfig), &legacy)
|
||||
}
|
||||
if legacy.Provider == ProviderLewan || legacy.LewanProductID != "" {
|
||||
cfg.RoutingMode = RoutingModeLewan
|
||||
cfg.LewanProductID = legacy.LewanProductID
|
||||
} else if legacy.Provider == ProviderDLC {
|
||||
cfg.RoutingMode = RoutingModeDLC
|
||||
cfg.DLCGoodID = legacy.DLCGoodID
|
||||
}
|
||||
}
|
||||
|
||||
// 默认兜底:若第一平台有内置 GoodID,补齐 DLCGoodID
|
||||
builtinDLC := deliveryGoodID("dlc", sku)
|
||||
if cfg.DLCGoodID == "" && builtinDLC != "" {
|
||||
cfg.DLCGoodID = builtinDLC
|
||||
}
|
||||
|
||||
// 若未配置 RoutingMode
|
||||
if cfg.RoutingMode == "" {
|
||||
if cfg.LewanProductID != "" && cfg.DLCGoodID != "" {
|
||||
cfg.RoutingMode = RoutingModePriority
|
||||
cfg.Priority = []string{ProviderLewan, ProviderDLC}
|
||||
} else if cfg.LewanProductID != "" {
|
||||
cfg.RoutingMode = RoutingModeLewan
|
||||
} else {
|
||||
cfg.RoutingMode = RoutingModeDLC
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Priority) == 0 && cfg.RoutingMode == RoutingModePriority {
|
||||
cfg.Priority = []string{ProviderLewan, ProviderDLC}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DeliveryOrderState 抽象商品履约状态
|
||||
type DeliveryOrderState struct {
|
||||
CanShip bool
|
||||
CannotShipReason string
|
||||
Product *DeliveryProduct
|
||||
Good map[string]interface{}
|
||||
Provider string
|
||||
}
|
||||
|
||||
// ProviderBindResult 抽象绑定返回
|
||||
type ProviderBindResult struct {
|
||||
BindUUID string `json:"bind_uuid"`
|
||||
BindURL string `json:"bind_url"`
|
||||
QRURL string `json:"qr_url"`
|
||||
Bound bool `json:"bound"`
|
||||
GameAccount string `json:"game_account,omitempty"`
|
||||
RoleName string `json:"role_name,omitempty"`
|
||||
GameChannel string `json:"game_channel,omitempty"`
|
||||
ExpectedGameAccount string `json:"expected_game_account,omitempty"`
|
||||
Mismatch bool `json:"mismatch,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// PatchResultData 绑定成功后写入订单 result_data(如 lewan_order_id)
|
||||
PatchResultData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// ProviderSubmitResult 抽象提交返回
|
||||
type ProviderSubmitResult struct {
|
||||
ProviderOrderNo string `json:"provider_order_no"`
|
||||
NextStatus string `json:"next_status"` // delivering / delivered
|
||||
Message string `json:"message"`
|
||||
GameAccount map[string]interface{} `json:"game_account,omitempty"`
|
||||
UpstreamOrder interface{} `json:"delivery,omitempty"`
|
||||
PatchResultData map[string]interface{} `json:"patch_result_data,omitempty"`
|
||||
}
|
||||
|
||||
// FulfillmentProvider 统一履约提供商接口
|
||||
type FulfillmentProvider interface {
|
||||
Name() string
|
||||
PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error)
|
||||
Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error)
|
||||
CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error)
|
||||
Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error)
|
||||
Cancel(ctx context.Context, order *model.FulfillmentOrder) error
|
||||
}
|
||||
|
||||
// FulfillmentRouter 履约路由器
|
||||
type FulfillmentRouter struct {
|
||||
providers map[string]FulfillmentProvider
|
||||
}
|
||||
|
||||
func NewFulfillmentRouter(providers ...FulfillmentProvider) *FulfillmentRouter {
|
||||
m := make(map[string]FulfillmentProvider)
|
||||
for _, p := range providers {
|
||||
if p != nil {
|
||||
m[p.Name()] = p
|
||||
}
|
||||
}
|
||||
return &FulfillmentRouter{providers: m}
|
||||
}
|
||||
|
||||
func (r *FulfillmentRouter) GetProvider(name string) FulfillmentProvider {
|
||||
return r.providers[name]
|
||||
}
|
||||
|
||||
// ResolveProviders 返回订单可选的履约渠道列表(按优先级排序)
|
||||
func (r *FulfillmentRouter) ResolveProviders(order *model.FulfillmentOrder) ([]FulfillmentProvider, ProductRoutingConfig) {
|
||||
sku := ""
|
||||
configStr := ""
|
||||
if order != nil {
|
||||
sku = order.ProductSKU
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
}
|
||||
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
|
||||
// 如果订单已有选定渠道记录(例如已经对乐玩或 DLC 发起过 bind/submit),则锁定该渠道
|
||||
if order != nil && strings.TrimSpace(order.ResultData) != "" {
|
||||
if active := resultDataString(order.ResultData, "provider"); active != "" {
|
||||
if p, ok := r.providers[active]; ok {
|
||||
return []FulfillmentProvider{p}, cfg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var list []FulfillmentProvider
|
||||
switch cfg.RoutingMode {
|
||||
case RoutingModeLewan:
|
||||
if p, ok := r.providers[ProviderLewan]; ok {
|
||||
list = append(list, p)
|
||||
}
|
||||
case RoutingModeDLC:
|
||||
if p, ok := r.providers[ProviderDLC]; ok {
|
||||
list = append(list, p)
|
||||
}
|
||||
case RoutingModePriority:
|
||||
for _, name := range cfg.Priority {
|
||||
if p, ok := r.providers[name]; ok {
|
||||
list = append(list, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终安全兜底:若解析为空,优先尝试 DLC,若无则尝试任意可用 provider
|
||||
if len(list) == 0 {
|
||||
if dlc, ok := r.providers[ProviderDLC]; ok {
|
||||
list = append(list, dlc)
|
||||
} else {
|
||||
for _, p := range r.providers {
|
||||
list = append(list, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list, cfg
|
||||
}
|
||||
|
||||
func qrCodeURL(targetURL string) string {
|
||||
return "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(targetURL)
|
||||
}
|
||||
|
||||
func truncateText(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
// DLCProvider 实现第一平台(聚鑫源 / dlc)履约逻辑
|
||||
type DLCProvider struct {
|
||||
bffBaseURL string
|
||||
channel string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDLCProvider(bffBaseURL, channel string, httpClient *http.Client) *DLCProvider {
|
||||
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
|
||||
if bffBaseURL == "" {
|
||||
bffBaseURL = defaultDeliveryBFFBaseURL
|
||||
}
|
||||
channel = strings.TrimSpace(channel)
|
||||
if channel == "" {
|
||||
channel = "dlc"
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 15 * timeSecond(15)}
|
||||
}
|
||||
return &DLCProvider{
|
||||
bffBaseURL: bffBaseURL,
|
||||
channel: channel,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DLCProvider) Name() string {
|
||||
return ProviderDLC
|
||||
}
|
||||
|
||||
func (p *DLCProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) {
|
||||
sku := ""
|
||||
configStr := ""
|
||||
if order != nil {
|
||||
sku = order.ProductSKU
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
}
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
goodID := cfg.DLCGoodID
|
||||
if goodID == "" {
|
||||
goodID = deliveryGoodID(p.channel, sku)
|
||||
}
|
||||
if goodID == "" {
|
||||
return &DeliveryOrderState{
|
||||
CanShip: false,
|
||||
CannotShipReason: "未找到对应的第一平台商品发货配置",
|
||||
Provider: ProviderDLC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var good map[string]interface{}
|
||||
good, _ = p.goodsDetail(goodID)
|
||||
|
||||
var deliveryProd *DeliveryProduct
|
||||
if openOrder != nil && openOrder.Product != nil {
|
||||
deliveryProd = buildDeliveryProduct(openOrder.Product, good)
|
||||
}
|
||||
|
||||
return &DeliveryOrderState{
|
||||
CanShip: true,
|
||||
Product: deliveryProd,
|
||||
Good: good,
|
||||
Provider: ProviderDLC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) {
|
||||
sku := order.ProductSKU
|
||||
configStr := ""
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
goodID := cfg.DLCGoodID
|
||||
if goodID == "" {
|
||||
goodID = deliveryGoodID(p.channel, sku)
|
||||
}
|
||||
if goodID == "" {
|
||||
return nil, errors.New("未找到对应的商品发货配置")
|
||||
}
|
||||
|
||||
var out struct {
|
||||
BindUUID string `json:"bind_uuid"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := p.signProxy("/public/games/bind-account", "POST", map[string]interface{}{
|
||||
"game_account": gameAccount,
|
||||
"gameAccount": gameAccount,
|
||||
"goodId": goodID,
|
||||
"good_id": goodID,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.BindUUID == "" || out.URL == "" {
|
||||
return nil, errors.New("绑定服务未返回绑定二维码")
|
||||
}
|
||||
|
||||
return &ProviderBindResult{
|
||||
BindUUID: out.BindUUID,
|
||||
BindURL: out.URL,
|
||||
QRURL: qrCodeURL(out.URL),
|
||||
Bound: false,
|
||||
Provider: ProviderDLC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) {
|
||||
sku := order.ProductSKU
|
||||
configStr := ""
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
goodID := cfg.DLCGoodID
|
||||
if goodID == "" {
|
||||
goodID = deliveryGoodID(p.channel, sku)
|
||||
}
|
||||
if goodID == "" {
|
||||
return nil, errors.New("未找到对应的商品发货配置")
|
||||
}
|
||||
|
||||
account, err := p.accountBound(bindUUID, goodID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "尚未绑定") || strings.Contains(err.Error(), "未绑定") {
|
||||
return &ProviderBindResult{
|
||||
BindUUID: bindUUID,
|
||||
Bound: false,
|
||||
Message: "尚未绑定,请先完成扫码绑定",
|
||||
Provider: ProviderDLC,
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boundAccount := stringFromMap(account, "game_account")
|
||||
result := &ProviderBindResult{
|
||||
BindUUID: bindUUID,
|
||||
Bound: true,
|
||||
GameAccount: boundAccount,
|
||||
RoleName: stringFromMap(account, "game_account_role_name"),
|
||||
GameChannel: gameChannelText(account),
|
||||
Message: "绑定成功",
|
||||
Provider: ProviderDLC,
|
||||
}
|
||||
|
||||
if expected := requestDataString(order.RequestData, "game_account"); expected != "" {
|
||||
result.ExpectedGameAccount = expected
|
||||
if boundAccount != expected {
|
||||
result.Mismatch = true
|
||||
result.Message = "绑定成功,但绑定账号与下单预期账号不一致,请确认"
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
|
||||
sku := order.ProductSKU
|
||||
configStr := ""
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
goodID := cfg.DLCGoodID
|
||||
if goodID == "" {
|
||||
goodID = deliveryGoodID(p.channel, sku)
|
||||
}
|
||||
if goodID == "" {
|
||||
return nil, errors.New("未找到对应的商品发货配置")
|
||||
}
|
||||
|
||||
boundAccount, err := p.accountBound(bindUUID, goodID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("绑定校验失败: %w", err)
|
||||
}
|
||||
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
|
||||
return nil, errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
|
||||
}
|
||||
|
||||
goodsNo := sku
|
||||
goodsName := order.ProductName
|
||||
|
||||
queueOrderID, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建发货队列失败: %w", err)
|
||||
}
|
||||
|
||||
if err := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil {
|
||||
return nil, fmt.Errorf("补充发货账号失败: %w", err)
|
||||
}
|
||||
|
||||
upstreamOrder, err := p.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建上游正式订单失败: %w", err)
|
||||
}
|
||||
|
||||
providerOrderNo := firstNonEmpty(
|
||||
stringFromMap(upstreamOrder, "order_no"),
|
||||
stringFromMap(upstreamOrder, "_id"),
|
||||
stringFromMap(upstreamOrder, "id"),
|
||||
stringFromMap(upstreamOrder, "order_sn"),
|
||||
queueOrderID,
|
||||
)
|
||||
|
||||
nextStatus := model.OrderStatusDelivering
|
||||
message := "已提交发货,等待发货结果回传"
|
||||
if upstreamDeliverySucceeded(upstreamOrder) {
|
||||
nextStatus = model.OrderStatusDelivered
|
||||
message = "发货已完成"
|
||||
}
|
||||
|
||||
return &ProviderSubmitResult{
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
NextStatus: nextStatus,
|
||||
Message: message,
|
||||
GameAccount: boundAccount,
|
||||
UpstreamOrder: upstreamOrder,
|
||||
PatchResultData: map[string]interface{}{
|
||||
"provider": ProviderDLC,
|
||||
"provider_order_stage": deliveryStageSubmitted,
|
||||
"queue_order_id": queueOrderID,
|
||||
"provider_order_no": providerOrderNo,
|
||||
"game_uid": gameAccount,
|
||||
"role_name": stringFromMap(boundAccount, "game_account_role_name"),
|
||||
"game_channel": gameChannelText(boundAccount),
|
||||
"delivery": upstreamOrder,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error {
|
||||
// DLC 不支持在已提交后自动取消,直接返回 nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DLC 内部 HTTP 交互
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func (p *DLCProvider) goodsDetail(goodID string) (map[string]interface{}, error) {
|
||||
var out struct {
|
||||
Good map[string]interface{} `json:"good"`
|
||||
}
|
||||
if err := p.signProxy("/public/goods/detail", "POST", map[string]interface{}{
|
||||
"good_id": goodID,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Good, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) accountBound(bindUUID, goodID string) (map[string]interface{}, error) {
|
||||
var out struct {
|
||||
GameAccount map[string]interface{} `json:"gameAccount"`
|
||||
Snake map[string]interface{} `json:"game_account"`
|
||||
}
|
||||
if err := p.signProxy("/public/games/account-bound", "POST", map[string]interface{}{
|
||||
"bind_uuid": bindUUID,
|
||||
"bindUuid": bindUUID,
|
||||
"goodId": goodID,
|
||||
"good_id": goodID,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.GameAccount == nil {
|
||||
out.GameAccount = out.Snake
|
||||
}
|
||||
if out.GameAccount == nil {
|
||||
return nil, errors.New("账号尚未绑定,请扫码完成绑定后再提交")
|
||||
}
|
||||
return out.GameAccount, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) createOrderQueue(goodID, orderNo, goodsNo string) (string, error) {
|
||||
var out struct {
|
||||
Orders []map[string]interface{} `json:"orders"`
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"good_id": goodID,
|
||||
"quantity": 1,
|
||||
"order_sn": orderNo,
|
||||
"h5_prefix": p.channel,
|
||||
}
|
||||
if goodsNo != "" {
|
||||
payload["goods_no"] = goodsNo
|
||||
}
|
||||
if err := p.signProxy("/public/users/orders-queue", "POST", payload, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Orders) == 0 {
|
||||
return "", errors.New("发货服务未返回队列订单")
|
||||
}
|
||||
orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id"))
|
||||
if orderID == "" {
|
||||
return "", errors.New("发货服务未返回队列订单 ID")
|
||||
}
|
||||
return orderID, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) patchOrderQueue(orderID, gameAccount, bindUUID, goodsNo, goodsName string) error {
|
||||
var out map[string]interface{}
|
||||
payload := map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"game_account": gameAccount,
|
||||
"bind_uuid": bindUUID,
|
||||
"h5_prefix": p.channel,
|
||||
}
|
||||
if goodsNo != "" {
|
||||
payload["goods_no"] = goodsNo
|
||||
}
|
||||
if goodsName != "" {
|
||||
payload["goods_name"] = goodsName
|
||||
}
|
||||
return p.signProxy("/public/users/orders-queue", "PATCH", payload, &out)
|
||||
}
|
||||
|
||||
func (p *DLCProvider) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) {
|
||||
var out struct {
|
||||
Order map[string]interface{} `json:"order"`
|
||||
}
|
||||
if err := p.signProxy("/public/users/orders", "POST", map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"game_account": gameAccount,
|
||||
"good_id": goodID,
|
||||
"h5_prefix": p.channel,
|
||||
"order_sn": orderNo,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Order == nil {
|
||||
out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo}
|
||||
}
|
||||
return out.Order, nil
|
||||
}
|
||||
|
||||
func (p *DLCProvider) signProxy(path, method string, data interface{}, out interface{}) error {
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"path": path,
|
||||
"method": strings.ToUpper(method),
|
||||
"data": data,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, p.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发货服务请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var envelope struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body)))
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) {
|
||||
if envelope.Message != "" {
|
||||
return errors.New(envelope.Message)
|
||||
}
|
||||
return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("发货服务数据无法解析:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func timeSecond(s int) time.Duration {
|
||||
return time.Duration(s) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/lewan"
|
||||
)
|
||||
|
||||
// LewanProvider 实现乐玩开放平台履约逻辑
|
||||
type LewanProvider struct {
|
||||
client *lewan.Client
|
||||
}
|
||||
|
||||
func NewLewanProvider(client *lewan.Client) *LewanProvider {
|
||||
return &LewanProvider{client: client}
|
||||
}
|
||||
|
||||
func (p *LewanProvider) Name() string {
|
||||
return ProviderLewan
|
||||
}
|
||||
|
||||
func (p *LewanProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) {
|
||||
if p.client == nil {
|
||||
return nil, errors.New("乐玩平台客户端未初始化")
|
||||
}
|
||||
sku := ""
|
||||
configStr := ""
|
||||
if order != nil {
|
||||
sku = order.ProductSKU
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
}
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
if cfg.LewanProductID == "" {
|
||||
return &DeliveryOrderState{
|
||||
CanShip: false,
|
||||
CannotShipReason: "未配置乐玩商品ID",
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
lewanProd, err := p.client.GetProduct(ctx, cfg.LewanProductID)
|
||||
if err != nil {
|
||||
return &DeliveryOrderState{
|
||||
CanShip: false,
|
||||
CannotShipReason: fmt.Sprintf("获取乐玩商品失败: %v", err),
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if lewanProd.AccessMode != "PURCHASE" {
|
||||
return &DeliveryOrderState{
|
||||
CanShip: false,
|
||||
CannotShipReason: "乐玩商品当前不可购买 (accessMode != PURCHASE)",
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
if lewanProd.Stock == 0 {
|
||||
return &DeliveryOrderState{
|
||||
CanShip: false,
|
||||
CannotShipReason: "乐玩商品当前无库存",
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
deliveryProd := &DeliveryProduct{
|
||||
Name: lewanProd.Name,
|
||||
SKU: sku,
|
||||
Game: lewanProd.CategoryName,
|
||||
Image: lewanProd.ThumbnailUrl,
|
||||
}
|
||||
if openOrder != nil && openOrder.Product != nil && openOrder.Product.Name != "" {
|
||||
deliveryProd.Name = openOrder.Product.Name
|
||||
}
|
||||
|
||||
goodMap := map[string]interface{}{
|
||||
"id": lewanProd.ID,
|
||||
"name": lewanProd.Name,
|
||||
"category_name": lewanProd.CategoryName,
|
||||
"stock": lewanProd.Stock,
|
||||
"delivery_type": lewanProd.DeliveryType,
|
||||
"price": lewanProd.EffectiveUnitPrice,
|
||||
}
|
||||
|
||||
return &DeliveryOrderState{
|
||||
CanShip: true,
|
||||
Product: deliveryProd,
|
||||
Good: goodMap,
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LewanProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) {
|
||||
if p.client == nil {
|
||||
return nil, errors.New("乐玩平台客户端未初始化")
|
||||
}
|
||||
sku := order.ProductSKU
|
||||
configStr := ""
|
||||
if order.MerchantProduct != nil {
|
||||
configStr = order.MerchantProduct.FulfillmentConfig
|
||||
}
|
||||
cfg := ParseProductRoutingConfig(configStr, sku)
|
||||
if cfg.LewanProductID == "" {
|
||||
return nil, errors.New("该商品未配置乐玩商品ID")
|
||||
}
|
||||
|
||||
// 1. 检查或获取现有乐玩单号
|
||||
lewanOrderID := resultDataString(order.ResultData, "lewan_order_id")
|
||||
var (
|
||||
lewanOrder *lewan.LewanOrder
|
||||
bindingAction *lewan.BindingAction
|
||||
err error
|
||||
)
|
||||
|
||||
if lewanOrderID != "" {
|
||||
// 已创建过乐玩订单,查单或恢复动作
|
||||
lewanOrder, err = p.client.GetOrder(ctx, lewanOrderID)
|
||||
if err == nil && lewanOrder != nil {
|
||||
bindingAction = lewanOrder.BindingAction
|
||||
if bindingAction != nil && bindingAction.BindingUrlExpired {
|
||||
restored, restoreErr := p.client.RestoreBindingAction(ctx, lewanOrderID)
|
||||
if restoreErr == nil && restored != nil {
|
||||
bindingAction = restored.BindingAction
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 若尚未建单,调乐玩下单
|
||||
if lewanOrder == nil {
|
||||
quantity := order.Quantity
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
createRes, createErr := p.client.CreateOrder(ctx, lewan.CreateOrderReq{
|
||||
CallerOrderID: order.OrderNo,
|
||||
RoleID: gameAccount,
|
||||
Items: []lewan.OrderItem{
|
||||
{
|
||||
ProductID: cfg.LewanProductID,
|
||||
Quantity: quantity,
|
||||
},
|
||||
},
|
||||
})
|
||||
if createErr != nil {
|
||||
// 如果提示 callerOrderId 已存在,尝试按 callerOrderId 查单
|
||||
if strings.Contains(createErr.Error(), "ALREADY_EXISTS") || strings.Contains(createErr.Error(), "DUPLICATE") {
|
||||
byCaller, getErr := p.client.GetOrderByCallerOrder(ctx, order.OrderNo)
|
||||
if getErr == nil && byCaller != nil {
|
||||
lewanOrder = byCaller
|
||||
bindingAction = byCaller.BindingAction
|
||||
} else {
|
||||
return nil, createErr
|
||||
}
|
||||
} else {
|
||||
return nil, createErr
|
||||
}
|
||||
} else if createRes != nil {
|
||||
lewanOrder = createRes.Order
|
||||
bindingAction = createRes.BindingAction
|
||||
}
|
||||
}
|
||||
|
||||
if lewanOrder == nil {
|
||||
return nil, errors.New("乐玩创建订单未返回有效订单数据")
|
||||
}
|
||||
|
||||
lewanOrderID = lewanOrder.ID
|
||||
patch := map[string]interface{}{
|
||||
"provider": ProviderLewan,
|
||||
"lewan_order_id": lewanOrderID,
|
||||
"lewan_order_no": lewanOrder.OrderNo,
|
||||
"lewan_status": lewanOrder.Status,
|
||||
}
|
||||
|
||||
// 3. 判断是否需要绑定
|
||||
if bindingAction != nil && !bindingAction.BindingRequired {
|
||||
// 角色已在乐玩绑定过,免扫码直接已绑定
|
||||
return &ProviderBindResult{
|
||||
BindUUID: lewanOrderID,
|
||||
Bound: true,
|
||||
GameAccount: gameAccount,
|
||||
Message: "账号已在平台授权,免扫码直接进入发货",
|
||||
Provider: ProviderLewan,
|
||||
PatchResultData: patch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
bindURL := ""
|
||||
if bindingAction != nil {
|
||||
bindURL = bindingAction.BindingUrl
|
||||
}
|
||||
qrURL := ""
|
||||
if bindURL != "" {
|
||||
qrURL = qrCodeURL(bindURL)
|
||||
}
|
||||
|
||||
return &ProviderBindResult{
|
||||
BindUUID: lewanOrderID,
|
||||
BindURL: bindURL,
|
||||
QRURL: qrURL,
|
||||
Bound: false,
|
||||
GameAccount: gameAccount,
|
||||
Message: "请扫码或打开链接完成授权绑定",
|
||||
Provider: ProviderLewan,
|
||||
PatchResultData: patch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LewanProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) {
|
||||
if p.client == nil {
|
||||
return nil, errors.New("乐玩平台客户端未初始化")
|
||||
}
|
||||
lewanOrderID := bindUUID
|
||||
if lewanOrderID == "" {
|
||||
lewanOrderID = resultDataString(order.ResultData, "lewan_order_id")
|
||||
}
|
||||
if lewanOrderID == "" {
|
||||
return nil, errors.New("缺少乐玩订单编号")
|
||||
}
|
||||
|
||||
refreshRes, err := p.client.RefreshBinding(ctx, lewanOrderID)
|
||||
if err != nil {
|
||||
// 查询订单兜底判断
|
||||
orderDetail, getErr := p.client.GetOrder(ctx, lewanOrderID)
|
||||
if getErr == nil && orderDetail != nil {
|
||||
if orderDetail.Status != "WAITING_BINDING" {
|
||||
return &ProviderBindResult{
|
||||
BindUUID: lewanOrderID,
|
||||
Bound: true,
|
||||
GameAccount: orderDetail.RoleID,
|
||||
Message: "绑定已完成",
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return &ProviderBindResult{
|
||||
BindUUID: bindUUID,
|
||||
Bound: false,
|
||||
Message: "尚未完成绑定,请继续扫码操作",
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
bound := false
|
||||
gameAccount := ""
|
||||
roleName := ""
|
||||
if refreshRes != nil && refreshRes.Binding != nil {
|
||||
bound = refreshRes.Binding.Matched
|
||||
gameAccount = refreshRes.Binding.RoleID
|
||||
roleName = refreshRes.Binding.RoleName
|
||||
}
|
||||
if refreshRes != nil && refreshRes.Order != nil && refreshRes.Order.Status != "WAITING_BINDING" {
|
||||
bound = true
|
||||
}
|
||||
|
||||
msg := "绑定成功"
|
||||
if !bound {
|
||||
msg = "尚未完成扫码绑定或角色未匹配"
|
||||
}
|
||||
|
||||
return &ProviderBindResult{
|
||||
BindUUID: lewanOrderID,
|
||||
Bound: bound,
|
||||
GameAccount: gameAccount,
|
||||
RoleName: roleName,
|
||||
Message: msg,
|
||||
Provider: ProviderLewan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LewanProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
|
||||
if p.client == nil {
|
||||
return nil, errors.New("乐玩平台客户端未初始化")
|
||||
}
|
||||
lewanOrderID := bindUUID
|
||||
if lewanOrderID == "" {
|
||||
lewanOrderID = resultDataString(order.ResultData, "lewan_order_id")
|
||||
}
|
||||
if lewanOrderID == "" {
|
||||
return nil, errors.New("缺少乐玩订单编号")
|
||||
}
|
||||
|
||||
// 刷新绑定状态并推动队列
|
||||
refreshRes, err := p.client.RefreshBinding(ctx, lewanOrderID)
|
||||
var lewanOrder *lewan.LewanOrder
|
||||
if refreshRes != nil && refreshRes.Order != nil {
|
||||
lewanOrder = refreshRes.Order
|
||||
} else {
|
||||
lewanOrder, _ = p.client.GetOrder(ctx, lewanOrderID)
|
||||
}
|
||||
|
||||
if lewanOrder == nil {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("提交乐玩发货失败: %w", err)
|
||||
}
|
||||
return nil, errors.New("未获取到乐玩订单")
|
||||
}
|
||||
|
||||
providerOrderNo := lewanOrder.OrderNo
|
||||
if providerOrderNo == "" {
|
||||
providerOrderNo = lewanOrder.ID
|
||||
}
|
||||
|
||||
nextStatus := model.OrderStatusDelivering
|
||||
message := "已提交乐玩平台发货,等待发货结果"
|
||||
if lewanOrder.Status == "SUCCEEDED" {
|
||||
nextStatus = model.OrderStatusDelivered
|
||||
message = "乐玩平台已发货完成"
|
||||
} else if lewanOrder.Status == "FAILED" {
|
||||
nextStatus = model.OrderStatusShipFailed
|
||||
message = "乐玩平台发货失败"
|
||||
}
|
||||
|
||||
roleName := ""
|
||||
if refreshRes != nil && refreshRes.Binding != nil {
|
||||
roleName = refreshRes.Binding.RoleName
|
||||
}
|
||||
|
||||
return &ProviderSubmitResult{
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
NextStatus: nextStatus,
|
||||
Message: message,
|
||||
GameAccount: map[string]interface{}{
|
||||
"game_account": gameAccount,
|
||||
"role_name": roleName,
|
||||
},
|
||||
UpstreamOrder: lewanOrder,
|
||||
PatchResultData: map[string]interface{}{
|
||||
"provider": ProviderLewan,
|
||||
"provider_order_stage": deliveryStageSubmitted,
|
||||
"provider_order_no": providerOrderNo,
|
||||
"lewan_order_id": lewanOrder.ID,
|
||||
"lewan_order_no": lewanOrder.OrderNo,
|
||||
"lewan_status": lewanOrder.Status,
|
||||
"game_uid": gameAccount,
|
||||
"role_name": roleName,
|
||||
"delivery": lewanOrder,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LewanProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error {
|
||||
if p.client == nil {
|
||||
return nil
|
||||
}
|
||||
lewanOrderID := resultDataString(order.ResultData, "lewan_order_id")
|
||||
if lewanOrderID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := p.client.CancelOrder(ctx, lewanOrderID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func TestParseProductRoutingConfig(t *testing.T) {
|
||||
// Case 1: 存量老商品未配置,根据 SKU 默认走 DLC
|
||||
cfg1 := ParseProductRoutingConfig("", "suit_pink_sheep")
|
||||
if cfg1.RoutingMode != RoutingModeDLC {
|
||||
t.Fatalf("expected RoutingModeDLC, got %s", cfg1.RoutingMode)
|
||||
}
|
||||
if cfg1.DLCGoodID != "682ef3c5a8f40c4234c59f4d" {
|
||||
t.Fatalf("expected builtin goodID 682ef3c5a8f40c4234c59f4d, got %s", cfg1.DLCGoodID)
|
||||
}
|
||||
|
||||
// Case 2: 仅配置乐玩
|
||||
cfg2JSON := `{"routing_mode":"lewan","lewan_product_id":"uuid-001","delivery_type":"LINK_BINDING"}`
|
||||
cfg2 := ParseProductRoutingConfig(cfg2JSON, "any_sku")
|
||||
if cfg2.RoutingMode != RoutingModeLewan {
|
||||
t.Fatalf("expected RoutingModeLewan, got %s", cfg2.RoutingMode)
|
||||
}
|
||||
if cfg2.LewanProductID != "uuid-001" {
|
||||
t.Fatalf("expected lewan_product_id uuid-001, got %s", cfg2.LewanProductID)
|
||||
}
|
||||
|
||||
// Case 3: 双平台优先级 (乐玩优先)
|
||||
cfg3JSON := `{"routing_mode":"priority","priority":["lewan","dlc"],"dlc_good_id":"dlc-123","lewan_product_id":"uuid-123"}`
|
||||
cfg3 := ParseProductRoutingConfig(cfg3JSON, "any_sku")
|
||||
if cfg3.RoutingMode != RoutingModePriority {
|
||||
t.Fatalf("expected RoutingModePriority, got %s", cfg3.RoutingMode)
|
||||
}
|
||||
if len(cfg3.Priority) != 2 || cfg3.Priority[0] != ProviderLewan || cfg3.Priority[1] != ProviderDLC {
|
||||
t.Fatalf("expected priority [lewan, dlc], got %+v", cfg3.Priority)
|
||||
}
|
||||
if cfg3.DLCGoodID != "dlc-123" || cfg3.LewanProductID != "uuid-123" {
|
||||
t.Fatalf("mismatched IDs: dlc=%s lewan=%s", cfg3.DLCGoodID, cfg3.LewanProductID)
|
||||
}
|
||||
|
||||
// Case 4: 兼容历史 legacy 单 provider 字段
|
||||
cfg4JSON := `{"provider":"lewan","lewan_product_id":"uuid-999"}`
|
||||
cfg4 := ParseProductRoutingConfig(cfg4JSON, "any_sku")
|
||||
if cfg4.RoutingMode != RoutingModeLewan || cfg4.LewanProductID != "uuid-999" {
|
||||
t.Fatalf("legacy lewan format failed: %+v", cfg4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentRouterResolution(t *testing.T) {
|
||||
dlc := NewDLCProvider("https://bff.example", "dlc", nil)
|
||||
lewan := NewLewanProvider(nil)
|
||||
router := NewFulfillmentRouter(dlc, lewan)
|
||||
|
||||
// Case 1: 存量 DLC 商品
|
||||
orderDLC := &model.FulfillmentOrder{
|
||||
ProductSKU: "suit_alan_walker",
|
||||
}
|
||||
providers, _ := router.ResolveProviders(orderDLC)
|
||||
if len(providers) != 1 || providers[0].Name() != ProviderDLC {
|
||||
t.Fatalf("expected DLC provider for suit_alan_walker, got %d providers", len(providers))
|
||||
}
|
||||
|
||||
// Case 2: 仅乐玩商品
|
||||
orderLewan := &model.FulfillmentOrder{
|
||||
ProductSKU: "custom_lewan_item",
|
||||
MerchantProduct: &model.MerchantProduct{
|
||||
FulfillmentConfig: `{"routing_mode":"lewan","lewan_product_id":"uuid-001"}`,
|
||||
},
|
||||
}
|
||||
providers, _ = router.ResolveProviders(orderLewan)
|
||||
if len(providers) != 1 || providers[0].Name() != ProviderLewan {
|
||||
t.Fatalf("expected Lewan provider, got %d providers", len(providers))
|
||||
}
|
||||
|
||||
// Case 3: 优先级路由 [lewan, dlc]
|
||||
orderPriority := &model.FulfillmentOrder{
|
||||
ProductSKU: "shared_item",
|
||||
MerchantProduct: &model.MerchantProduct{
|
||||
FulfillmentConfig: `{"routing_mode":"priority","priority":["lewan","dlc"],"lewan_product_id":"uuid-001","dlc_good_id":"dlc-001"}`,
|
||||
},
|
||||
}
|
||||
providers, _ = router.ResolveProviders(orderPriority)
|
||||
if len(providers) != 2 || providers[0].Name() != ProviderLewan || providers[1].Name() != ProviderDLC {
|
||||
t.Fatalf("expected [lewan, dlc], got %v", providers)
|
||||
}
|
||||
|
||||
// Case 4: 订单已锁定渠道(已在 result_data 中写明 provider=dlc)
|
||||
orderLocked := &model.FulfillmentOrder{
|
||||
ProductSKU: "shared_item",
|
||||
MerchantProduct: &model.MerchantProduct{
|
||||
FulfillmentConfig: `{"routing_mode":"priority","priority":["lewan","dlc"],"lewan_product_id":"uuid-001","dlc_good_id":"dlc-001"}`,
|
||||
},
|
||||
ResultData: `{"provider":"dlc"}`,
|
||||
}
|
||||
providers, _ = router.ResolveProviders(orderLocked)
|
||||
if len(providers) != 1 || providers[0].Name() != ProviderDLC {
|
||||
t.Fatalf("expected locked DLC provider, got %+v", providers)
|
||||
}
|
||||
|
||||
// Case 5: 优先级 [dlc, lewan]
|
||||
orderDLCFirst := &model.FulfillmentOrder{
|
||||
ProductSKU: "shared_item",
|
||||
MerchantProduct: &model.MerchantProduct{
|
||||
FulfillmentConfig: `{"routing_mode":"priority","priority":["dlc","lewan"],"lewan_product_id":"uuid-001","dlc_good_id":"dlc-001"}`,
|
||||
},
|
||||
}
|
||||
providers, _ = router.ResolveProviders(orderDLCFirst)
|
||||
if len(providers) != 2 || providers[0].Name() != ProviderDLC || providers[1].Name() != ProviderLewan {
|
||||
t.Fatalf("expected [dlc, lewan], got %v", providerNames(providers))
|
||||
}
|
||||
}
|
||||
|
||||
func providerNames(providers []FulfillmentProvider) []string {
|
||||
names := make([]string, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
names = append(names, p.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
Reference in New Issue
Block a user