diff --git a/.env.example b/.env.example index 407ad20..f905fe6 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,13 @@ DELIVERY_LINK_TTL_MINUTES=0 FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30 FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60 +# 乐玩开放平台配置 +LEWAN_BASE_URL=https://shop.cloudtentacles.com +LEWAN_APPLICATION_ID= +LEWAN_ACCESS_KEY= +LEWAN_SECRET_KEY= +LEWAN_WEBHOOK_SECRET= + # 跨域来源白名单(逗号分隔)。生产前后端同域(Caddy 统一入口)无需配置; # 仅前后端分离部署时设置,例如:CORS_ALLOWED_ORIGINS=https://admin.example.com # 为空时仅放行本地开发端口 localhost:15173 / 18080;不支持 "*"(与凭证模式冲突)。 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8e1d998..481cffd 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "affiliate_dash/internal/handler" "affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/jwt" + "affiliate_dash/internal/pkg/lewan" "affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/timeutil" "affiliate_dash/internal/router" @@ -84,6 +85,18 @@ func main() { cfg.DeliveryLinkTTLMinutes, ) + // 乐玩开放平台客户端与履约提供商注册 + lewanClient := lewan.NewClient(lewan.Config{ + BaseURL: cfg.LewanBaseURL, + AccessKey: cfg.LewanAccessKey, + SecretKey: cfg.LewanSecretKey, + ApplicationID: cfg.LewanApplicationID, + WebhookSecret: cfg.LewanWebhookSecret, + }, nil) + lewanProvider := service.NewLewanProvider(lewanClient) + deliverySvc.RegisterProvider(lewanProvider) + fulfillmentSvc.SetUpstreamCancel(deliverySvc.CancelUpstream) + openlog.Init(cfg.OpenAPIDebug) h := &router.Handlers{ @@ -105,6 +118,8 @@ func main() { OpenAPIDebug: cfg.OpenAPIDebug, UploadDir: cfg.UploadDir, CORSAllowedOrigins: cfg.CORSAllowedOrigins, + LewanWebhook: handler.NewLewanWebhookHandler(fulfillmentSvc, db, cfg.LewanWebhookSecret, cfg.LewanApplicationID), + PlatformLewan: handler.NewPlatformLewanHandler(merchantSvc, lewanClient), } go callbackSvc.Run(context.Background()) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a737a77..60763bf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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", ""), } } diff --git a/backend/internal/handler/lewan_webhook.go b/backend/internal/handler/lewan_webhook.go new file mode 100644 index 0000000..d311a54 --- /dev/null +++ b/backend/internal/handler/lewan_webhook.go @@ -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 +} diff --git a/backend/internal/handler/platform_lewan.go b/backend/internal/handler/platform_lewan.go new file mode 100644 index 0000000..1574f99 --- /dev/null +++ b/backend/internal/handler/platform_lewan.go @@ -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) +} diff --git a/backend/internal/pkg/lewan/client.go b/backend/internal/pkg/lewan/client.go new file mode 100644 index 0000000..05ec99d --- /dev/null +++ b/backend/internal/pkg/lewan/client.go @@ -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 +} diff --git a/backend/internal/pkg/lewan/signer.go b/backend/internal/pkg/lewan/signer.go new file mode 100644 index 0000000..0347676 --- /dev/null +++ b/backend/internal/pkg/lewan/signer.go @@ -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 +} diff --git a/backend/internal/pkg/lewan/signer_test.go b/backend/internal/pkg/lewan/signer_test.go new file mode 100644 index 0000000..af35d88 --- /dev/null +++ b/backend/internal/pkg/lewan/signer_test.go @@ -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") + } + }) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2c155da..51be816 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index a4ae2f7..1d5e1a8 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -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 { diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 35e105f..39e5204 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -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 diff --git a/backend/internal/service/merchant_catalog.go b/backend/internal/service/merchant_catalog.go index 73e2d74..2b52e77 100644 --- a/backend/internal/service/merchant_catalog.go +++ b/backend/internal/service/merchant_catalog.go @@ -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 +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go new file mode 100644 index 0000000..1d6eb10 --- /dev/null +++ b/backend/internal/service/provider.go @@ -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] + "..." +} diff --git a/backend/internal/service/provider_dlc.go b/backend/internal/service/provider_dlc.go new file mode 100644 index 0000000..5831549 --- /dev/null +++ b/backend/internal/service/provider_dlc.go @@ -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 +} diff --git a/backend/internal/service/provider_lewan.go b/backend/internal/service/provider_lewan.go new file mode 100644 index 0000000..26d27b3 --- /dev/null +++ b/backend/internal/service/provider_lewan.go @@ -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 +} diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go new file mode 100644 index 0000000..4fa8170 --- /dev/null +++ b/backend/internal/service/provider_test.go @@ -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 +} diff --git a/docs/乐玩开放平台接入文档-V1.2.md b/docs/乐玩开放平台接入文档-V1.2.md new file mode 100644 index 0000000..8d8c200 --- /dev/null +++ b/docs/乐玩开放平台接入文档-V1.2.md @@ -0,0 +1,2883 @@ +# 开放平台接入文档 + +版本:V1.2 + +Base URL:`https://shop.cloudtentacles.com` + +## 快速开始 + +先完成以下五步,再参考各接口的参数与示例。下单受理不等于发货成功,请以逐件结果为准。 + +Base URL`https://shop.cloudtentacles.com` + +接口前缀`/openapi/v1` + +鉴权协议**HMAC-SHA256 / V1** + +数据格式**UTF-8 JSON** + +1. **开通权限**登录用户必须实时具备 `openapi:use`,然后在开放平台创建应用。 +2. **配置来源 IP**所有 POST 写请求必须命中应用 IP 白名单;GET 查询仍需签名,但不要求白名单。 +3. **签发密钥**妥善保存 AccessKey 与仅显示一次的 SecretKey,调用时不使用浏览器 JWT。 +4. **查询并下单**先读取商品的 accessMode、库存、发货方式与角色累计发货上限,只对 PURCHASE 商品幂等下单。 +5. **接收异步结果**配置 HTTPS Webhook,并同时保留订单查询作为补偿手段。 + +V1 仅支持 LINK\_BINDING 和 EMAIL\_BINDING;CDK 商品不会出现在商品列表中,也不能下单。 + +**统一响应包装** + +成功响应为 `{ data, requestId }`;失败响应为 `{ statusCode, error, message, code, requestId, timestamp, path }`。响应头 `X-Request-Id` 与响应体的 `requestId` 一致。 + +**应用 ID 在哪里查看** + +进入开放平台 → 应用密钥,在应用名称下方查看并复制应用 ID(`applicationId`);签发或轮换密钥的弹窗也会显示该应用 ID。 + +`applicationId` 标识应用,与 `AccessKey` 不同;同一应用可以有多个 AccessKey,但共用一个应用 ID。普通签名请求通过 `X-OpenAPI-Access-Key` 定位应用,无需另传 applicationId;收到 Webhook 后,按其 applicationId 选择对应应用的 WebhookSecret 验签。 + +管理接口中,应用列表的 `data.items[].id`、签发密钥响应的 `data.applicationId` 都是应用 ID;签发密钥响应的 `data.id` 是密钥记录 ID,不要混用。 + +**密钥只保存在服务端** + +SecretKey 仅在签发时显示一次,只能保存在调用方服务端。不要放入网页、移动端、URL、日志或工单。 + +### 调用配额与重试 + +| 查询/分钟 | 下单请求/分钟 | 下单件数/分钟 | 绑定刷新/分钟 | +| --- | --- | --- | --- | +| 1800 | 300 | 600 | 600 | + +- 表中为单应用按每分钟折算的持续速率,另有短时突发保护。同一用户的多个应用共享用户总额度,全平台另有总量保护,不能把各应用额度直接相加。 +- 取消、账号退还及邮箱验证/重发各有独立限制,不占绑定刷新额度。 +- `binding/action` 与 `binding/refresh` 共用绑定刷新额度及单订单冷却,最快每 5 秒一次;优先使用 Webhook,查询用于补偿,不要对全部历史订单持续轮询。 +- 遇到 `429` 按响应头 `Retry-After` 等待并加入少量随机延迟;重试下单保留原 `callerOrderId`,但使用新的时间戳、nonce 和签名。 +- 下单请求每次计数;已经受理的相同幂等订单不重复消耗件数额度。入口配额不是渠道履约速度或硬件容量承诺。 + +## 鉴权与签名 + +每个请求都必须携带以下四个请求头。服务端会统一返回 `X-Request-Id`,请在报错和技术支持场景中保留。 + +| 请求头 | 规则 | +| --- | --- | +| `X-OpenAPI-Access-Key` | 应用签发的 AccessKey,16 至 64 位;首位为字母或数字,其余可含下划线和连字符 | +| `X-OpenAPI-Timestamp` | Unix 秒级时间戳,必须位于服务端时间 ±5 分钟内 | +| `X-OpenAPI-Nonce` | 每次请求唯一的 16 至 64 位字母、数字、下划线或连字符,首位必须是字母或数字 | +| `X-OpenAPI-Signature` | HMAC-SHA256 小写十六进制,可选带 v1= 前缀 | + +### 六行待签名串 + +1. `HTTP_METHOD` +2. `PATH` +3. `RFC3986_QUERY` +4. `SHA256_HEX_OF_RAW_BODY` +5. `UNIX_TIMESTAMP_SECONDS` +6. `NONCE` + +- HTTP 方法必须转为大写。 +- PATH 是完整路径,不包含域名、查询串和片段,例如 `/openapi/v1/orders`。 +- 查询参数按 RFC 3986 编码,再按编码后的键和值使用码点顺序排序;重复键必须保留。 +- 规范化基于查询串解析后的键值对;空格统一编码为 `%20`,不使用 `+`。 +- 请求体摘要基于实际发送的原始字节;签名后不能再次格式化 JSON。 +- GET 或无请求体 POST 使用空字节摘要 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。 +- 六行之间仅使用一个 LF 字符 `\n`,末尾不追加换行。 + +`signature = hex(HMAC-SHA256(SecretKey, signingString))` + +Nonce 一旦被同一密钥接受,在防重放窗口内不可再次使用;重试必须生成新 Timestamp、Nonce 和 Signature。业务幂等由 callerOrderId 保证。 + +## 固定验签测试向量 + +以下值与服务端回归测试共用,可直接放入调用方单元测试,用于发现查询串规范化、原始请求体或 Webhook 验签的差异。 + +这是离线回归向量,固定密钥是公开测试数据,固定时间戳也已不适用于实时请求。不要将这些值用作真实应用凭据或发送到线上接口。 + +签名版本`v1` + +固定时间戳`1788566400` + +测试 AccessKey`ak_test_fixed_20260905` + +测试 SecretKey`sk_test_fixed_20260905` + +### 1. GET 查询 + +| 方法 / PATH | `GET /openapi/v1/products` | +| --- | --- | +| 原始查询串 | `pageSize=20&page=1` | +| 规范查询串 | `page=1&pageSize=20` | +| Nonce | `nonce_get_20260905_fixed` | +| 请求体 SHA-256 | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | + +**原始请求体** + +``` +(空字符串) +``` + +**六行待签名串** + +``` +GET +/openapi/v1/products +page=1&pageSize=20 +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +1788566400 +nonce_get_20260905_fixed +``` + +**预期请求签名**`54ea478585e61230be670d664745c81ffb4907b87c8592bf082c9cc894232adf` + +### 2. POST JSON + +| 方法 / PATH | `POST /openapi/v1/orders` | +| --- | --- | +| 原始查询串 | `(空字符串)` | +| 规范查询串 | `(空字符串)` | +| Nonce | `nonce_post_20260905_fixed` | +| 请求体 SHA-256 | `146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032` | + +**原始请求体** + +``` +{"callerOrderId":"DOC-20260905-001","roleId":"1148711973","items":[{"productId":"00000000-0000-4000-8000-000000000101","quantity":2}]} +``` + +**六行待签名串** + +``` +POST +/openapi/v1/orders + +146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032 +1788566400 +nonce_post_20260905_fixed +``` + +**预期请求签名**`1e1d3df46b885e76b1e992585215e488c29315e7207e62df4aa26b0124d04968` + +### 3. 重复查询参数 + +| 方法 / PATH | `GET /openapi/v1/orders` | +| --- | --- | +| 原始查询串 | `status=SUCCEEDED&keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED` | +| 规范查询串 | `keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED&status=SUCCEEDED` | +| Nonce | `nonce_repeat_20260905_fixed` | +| 请求体 SHA-256 | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | + +**原始请求体** + +``` +(空字符串) +``` + +**六行待签名串** + +``` +GET +/openapi/v1/orders +keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED&status=SUCCEEDED +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +1788566400 +nonce_repeat_20260905_fixed +``` + +**预期请求签名**`c1c2be18372e3ac89e8042f3477fff8287126e31777a18a593c0cce7028626d8` + +### 4. POST 空请求体 + +| 方法 / PATH | `POST /openapi/v1/orders/00000000-0000-4000-8000-000000000201/account/release` | +| --- | --- | +| 原始查询串 | `(空字符串)` | +| 规范查询串 | `(空字符串)` | +| Nonce | `nonce_empty_20260905_fixed` | +| 请求体 SHA-256 | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | + +**原始请求体** + +``` +(空字符串) +``` + +**六行待签名串** + +``` +POST +/openapi/v1/orders/00000000-0000-4000-8000-000000000201/account/release + +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +1788566400 +nonce_empty_20260905_fixed +``` + +**预期请求签名**`167f6d7c116fc7e51b0d2418e65a0705f08076940025f2c013adbd8f27648908` + +### Webhook 固定向量 + +| Webhook Secret | `whsec_test_fixed_20260905` | +| --- | --- | +| Timestamp | `1788566400` | +| Application ID | `00000000-0000-4000-8000-000000000301` | +| Event ID | `00000000-0000-4000-8000-000000000302` | + +**Webhook 原始请求体** + +``` +{"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"} +``` + +**Webhook 待签名字节** + +``` +1788566400.00000000-0000-4000-8000-000000000302.{"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"} +``` + +**预期 Webhook 签名**`v1=ee3968ef467a4f9c19c0a33e7af1bb3118fab8d02c5ebf9cbd8001cc34ebe6be` + +## 查询商品 + +GET`/openapi/v1/products` + +| 参数 | 类型与限制 | 说明 | +| --- | --- | --- | +| `categoryId` | UUID v4,可选 | 包含指定分类及其后代分类 | +| `keyword` | 1–100 字符,可选 | 匹配商品名称或简介 | +| `page` | 1–10000,默认 1 | 页码 | +| `pageSize` | 1–100,默认 50 | 每页数量 | + +- 返回当前账号有查看权限、在售、有库存、媒体可用且渠道映射有效的 LINK / EMAIL 商品。 +- `accessMode=VIEW` 只能展示;只有 `accessMode=PURCHASE` 才能下单,服务端会实时复核。 +- `effectiveUnitPrice` 是当前账号实际支付的最小货币单位,使用十进制字符串防止精度丢失。 +- 当前 `CREDIT` 的 `cnyCentRate=1`,即 1 CREDIT = 人民币 1 分;下单只传数量,不回传或信任客户端价格。 +- 开放 API 下单后直接进入绑定与发货流程,不进入商城背包;其数量限制与商城单次购买上限相互独立。 +- `roleDeliveryLimit` 为同一 SKU + 经验证角色 ID 的全系统累计发货上限;`null` 表示不限,更换手机号不会重置。 +- `thumbnailUrl` 始终是可直接访问的绝对 HTTPS 地址,不会返回 `/uploads/...` 相对路径。 +- `updatedAt` 是基础商品记录的更新时间;`productVersion` 是对该应用所属用户实际可见投影(包括成交价)计算的 `sha256:` 版本指纹。 + +GET`/openapi/v1/products/{id}` + +详情接口与列表使用完全相同的可见性、售卖窗口、库存、媒体与渠道映射条件;不存在或当前不可用统一返回 `OPENAPI_PRODUCT_NOT_FOUND`。 + +当前版本不支持 updatedSince 增量同步。请分页全量拉取,以 productVersion 判断单个商品投影是否变化;不要把 updatedAt 当成完整增量游标。 + +**成功响应示例** + +``` +{ + "data": { + "items": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "categoryId": "55555555-5555-4555-8555-555555555555", + "categoryName": "永劫无间", + "name": "示例商品", + "summary": "商品简介", + "thumbnailUrl": "https://shop.cloudtentacles.com/api/v1/media/commerce/example.webp", + "stock": 500, + "roleDeliveryLimit": 10, + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "accessMode": "PURCHASE", + "updatedAt": "2026-09-05T00:00:00.000Z", + "productVersion": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "total": 1, + "page": 1, + "pageSize": 50 + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +## 下单、并行与幂等 + +POST`/openapi/v1/orders` + +### 请求限制 + +- `callerOrderId` 长度 1–128,只能包含字母、数字、点、下划线、冒号和连字符。 +- `roleId` 长度 1–191,去除首尾空白,不能包含控制字符。 +- `items` 为 1–10 行;单请求最多包含 10 种不同商品,同一 `productId` 不允许重复。 +- 每行 `quantity` 必须是正整数且受 PostgreSQL `INTEGER` 存储边界限制;整单总量还会按下单件数风控策略校验。 +- 业务额度按“同一 SKU + 经验证角色 ID”累计发货上限计算;任一商品剩余额度不足时整单立即拒绝。 +- 角色累计额度在创建订单时原子预占;明确失败释放,结果未知时继续占用,避免并发绕过。 +- 一张订单内必须是同一个分类、同一种发货方式。 + +### 处理模型 + +- 不同订单且使用不同手机号时可并行;同一手机号的发货严格串行。 +- 同一 SKU 明确失败会停止其尚未开始的剩余件,其他 SKU 继续。 +- 已经开始的单件继续确认,因此完成顺序和 Webhook 顺序不保证与请求 items 顺序一致。 +- 若不同系统 SKU 映射到相同的上游 game + productId,为保证安全对账也会串行。 +- 渠道提交结果未知时按 2 / 4 / 8 / 16 / 32 秒进行五次对账;仍无法确认时进入人工异常处理,绝不自动重复发货。 + +**请求体** + +``` +{ + "callerOrderId": "merchant-order-20260824-0001", + "roleId": "123456789", + "items": [ + { + "productId": "11111111-1111-4111-8111-111111111111", + "quantity": 2 + }, + { + "productId": "22222222-2222-4222-8222-222222222222", + "quantity": 1 + } + ] +} +``` + +**LINK 待绑定响应** + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 3, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "idempotentReplay": false + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**callerOrderId 幂等规则** + +它在同一应用内永久不可复用。相同规范化请求重放会返回原订单,并将 `idempotentReplay` 设为 true;相同单号但请求内容不同返回 `CALLER_ORDER_ID_CONFLICT`。即使订单终态或超出列表查询窗口,该单号也不会释放给新订单。 + +收到冲突时**禁止自动换号重发**。先按原单号精确查单,核对角色、商品、数量和原业务请求;若仍属于原业务,则继续跟踪原单。只有明确确认是一次新的业务发货后,才使用新 `callerOrderId`。 + +**下单超时时精确查单** + +调用 `GET /openapi/v1/orders/by-caller-order-id?callerOrderId=...`。该查询只能读取当前鉴权应用的订单,不受列表接口 90 天时间窗口限制。查到原单就继续跟踪;明确返回 `OPENAPI_ORDER_NOT_FOUND` 后,才可用原参数和原 `callerOrderId` 幂等重试。 + +## 金额、结算与资金流水 + +当前换算口径:1 CREDIT = 人民币 1 分,100 CREDIT = 人民币 1 元。所有金额均是最小单位的十进制字符串,请使用整数或 BigInt 计算,禁止浮点运算。 + +### 订单与商品行金额字段 + +| 字段 | 类型 | 口径 | +| --- | --- | --- | +| `effectiveUnitPrice` | 十进制字符串 | 该商品行创建订单时的单价快照 | +| `grandTotal` / `orderedAmount` | 十进制字符串 | 原始下单金额;商品行为单价 × 数量 | +| `settledAmount` | 十进制字符串 | 发货成功并已结算的金额 | +| `releasedAmount` | 十进制字符串 | `FAILED`、`STOPPED`、`CANCELLED` 单件已退回可用余额的金额;不能只乘 releasedQuantity | +| `heldAmount` | 十进制字符串 | 结果尚未明确、仍被冻结的金额;订单终态必须为 `"0"` | +| `feeAmount` | 十进制字符串 | 平台手续费;当前固定为 `"0"` | + +对订单和每个商品行都应满足 `orderedAmount = settledAmount + releasedAmount + heldAmount`。响应同时携带 `currency.code`、`name`、`scale` 和 `cnyCentRate`;未配置人民币换算的未来币种会返回 `cnyCentRate=null`,不得当作 1:1。 + +商品行的 `releasedAmount = effectiveUnitPrice × (failedQuantity + releasedQuantity)`;`settledAmount = effectiveUnitPrice × succeededQuantity`,`heldAmount = effectiveUnitPrice × pendingQuantity`。整单金额分别汇总各行,不能用某一商品单价乘整单数量。 + +GET`/openapi/v1/wallets` + +- `scope=OWNER_SHARED` 表示这是应用所属用户的共享钱包,网页商城及同一用户的其他开放应用会共同改变余额。 +- `availableBalance` 为可用余额,`heldBalance` 包含所有业务中的冻结,`totalBalance` 为两者之和。 + +GET`/openapi/v1/wallet-transactions` + +- `scope=APPLICATION_ORDERS` 仅返回当前鉴权应用订单产生的 `HOLD`、`SETTLE` 和 `RELEASE`,不包含其他应用、网页商城、充值或赠送流水。 +- 可按 `effect`、`currencyCode`、`startedAt` / `endedAt` 筛选;默认 30 天,单次跨度最多 90 天。 +- 使用不透明 `cursor` 翻页;`hasMore=true` 时将 `nextCursor` 原样传给下一页,不要解析或自行构造。 + +**钱包余额响应** + +``` +{ + "data": { + "scope": "OWNER_SHARED", + "items": [ + { + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "active": true, + "cnyCentRate": 1 + }, + "availableBalance": "125000", + "heldBalance": "4500", + "totalBalance": "129500", + "updatedAt": "2026-09-05T00:00:00.000Z" + } + ] + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**当前应用订单资金流水** + +``` +{ + "data": { + "scope": "APPLICATION_ORDERS", + "items": [ + { + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "effect": "SETTLE", + "amount": "1000", + "availableDelta": "0", + "heldDelta": "-1000", + "availableBalanceAfter": "125000", + "heldBalanceAfter": "3500", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "active": true, + "cnyCentRate": 1 + }, + "orderId": "33333333-3333-4333-8333-333333333333", + "callerOrderId": "merchant-order-20260824-0001", + "createdAt": "2026-09-05T00:00:00.000Z" + } + ], + "hasMore": false, + "nextCursor": null, + "pageSize": 50, + "startedAt": "2026-08-06T00:00:00.000Z", + "endedAt": "2026-09-05T00:00:00.000Z" + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +## LINK / EMAIL 绑定流程 + +下单时系统优先按 `roleId` 匹配手机号最后一次实时绑定的角色,仍需重新登录并实时核对当前绑定。确认角色一致时 `bindingRequired=false`,订单直接排队;未匹配才返回绑定动作。 + +并发幂等下单时,bindingAction 可能暂时返回 allocationInProgress=true。请使用原 callerOrderId 和完全相同的请求体重试下单,但每次生成新的 Timestamp、Nonce 和 Signature;不要创建新业务单号。 + +### 重新进入页面与链接恢复 + +1. 先调用 `GET /openapi/v1/orders/{id}`,没有平台 UUID 时按原 `callerOrderId` 精确查单。`data.bindingAction` 是随订单保存的只读快照;GET 不生成新链接、不发送验证码、也不查询上游。 +2. 绑定链接有效期为 **3 分钟**,从后端取得链接的 `bindingUrlIssuedAt` 起算,以服务端返回的 `bindingUrlExpiresAt` 为准。订单仍为 `WAITING_BINDING`、租约和绑定窗口均未结束且链接未过期时,才展示链接或二维码。 +3. 链接缺失或过期时,调用 `POST /openapi/v1/orders/{id}/binding/action`,从 `data.bindingAction` 取得恢复结果;仍使用原订单。3 分钟内重复调用也复用同一链接,不重新请求渠道、不重置链接到期时间;到期后再次调用才获取新链接,没有自动后台刷新。 +4. 普通查单不生成新链接;已有链接时,相同 `callerOrderId` 的幂等重放也不替换链接,尚未完成初始化的原单仍可能生成首个链接。链接过期后返回 `bindingUrl=null`、`bindingUrlExpired=true`,保留签发和到期时间,并提供 `bindingActionEndpoint` 恢复入口。不要继续展示已过期二维码,也不要因此创建新业务订单。 +5. 链接自然到期不会主动增加 `orderVersion`;即使订单版本未变,也须根据 `bindingUrlExpiresAt` 停止展示旧链接。缓存命中不增加版本;实际重新生成时,预留和写入新链接会更新版本。 +6. `allocationInProgress=true` 表示账号还在分配;`bindingActionInProgress=true` 表示绑定链接仍在生成。请等冷却后查询原单或重试原动作,勿并发申请或换新订单号。 +7. EMAIL 已发送时恢复接口返回已有发送状态;需要重新发送时使用 `email/resend`。验证码业务冷却至少 60 秒,进行中的发送 / 验证会推迟 `resendAvailableAt`;即使倒计时已结束,仍须遵循额外 HTTP 429 的 `Retry-After`。 +8. 重新生成链接和重发邮箱验证码都不会延长 `bindingDeadline` 或租期。窗口过期后先查原单终态,确认未发货数量及冻结释放结果,再由业务决定是否新建发货单;不能直接自动换号。 + +| bindingAction 字段 | 类型 / 含义 | +| --- | --- | +| `bindingRequired` / `bindingType` | 是否仍需绑定,以及 LINK\_BINDING / EMAIL\_BINDING;非等待绑定状态的历史时间不代表仍允许操作 | +| `expiresAt` / `bindingDeadline` | ISO 8601 绝对时间;前者是手机号租期、未分配时为 null,后者是订单绑定窗口截止时间 | +| `bindingUrl` / `bindingUrlIssuedAt` | 当前可用的绑定入口和后端取得链接的时间,未生成时为 null;过期仅隐藏链接,保留时间;离开 WAITING\_BINDING 后两者隐藏为 null | +| `bindingUrlExpiresAt` | ISO 8601 到期时间,为 bindingUrlIssuedAt 加 180 秒;没有链接或不再等待绑定时为 null。expiresAt 是租约截止时间,不能代替链接 TTL | +| `bindingUrlExpired` / `bindingActionEndpoint` | 链接是否已过期;仅 LINK 链接已过期且当前仍可绑定时提供恢复动作的 POST 路径,其余情况为 null。过期判断不会自动向渠道请求新链接,租约或绑定窗口结束后不能恢复 | +| `emailCodeSent` / `emailCodeSentAt` | 是否已有成功发送记录及其绝对时间;未知时间为 null,不代表验证码仍有效 | +| `resendAvailableAt` | 按已保存状态推导的最早业务重发时间(ISO 8601 或 null);并非所有限流的通过承诺 | +| `allocationInProgress` / `bindingActionInProgress` | 分别表示账号分配中、LINK 动作生成中;依据订单版本更新显示状态 | + +所有时间均为带时区的绝对时间,客户端自行计算倒计时。`bindingUrl` 来自上游返回的 HTTPS `redirectUrl`,调用方应直接展示该地址;平台不会把它替换为管理页面。上游可能使用跳转入口,最终游戏直链和固定域名范围尚未获得明确保证,不能自行拼接域名。链接 3 分钟有效期、30 分钟租约和绑定校验有效期相互独立。 + +**LINK\_BINDING** + +1. 读取创建订单响应中的 `bindingAction.bindingUrl`、`bindingUrlExpiresAt` 和租约 `expiresAt`。 +2. 将链接或二维码展示给最终用户,等待其完成绑定。 +3. 调用 `POST /orders/{id}/binding/refresh` 刷新最新角色。 +4. `matched=true` 后自动进入发货;不匹配时展示返回的角色信息并允许再次刷新。 + +**EMAIL\_BINDING** + +1. 创建订单会尝试发送验证码,并返回 `emailCodeSent` 或发送中状态。 +2. 用户收到验证码后调用 `POST /orders/{id}/email/verify`,请求体为 `{ "code": "123456" }`。 +3. 只有返回角色与订单 `roleId` 一致才会自动进入发货。 +4. 确需重发时调用 `POST /orders/{id}/email/resend`,并遵循服务端限频。 + +绑定窗口最长 30 分钟。超时后系统自动取消未发放商品、释放冻结资产和库存,并推送 BINDING\_TIMEOUT。 + +- `BINDING_UPDATED` 表示通过 LINK 显式刷新或 EMAIL 验证获得了新绑定快照;`BINDING_VERIFIED` 表示最新角色已与订单 `roleId` 匹配。 +- 首次按已绑定 `roleId` 直接匹配时也会产生验证事件。平台当前不会在绑定窗口内无限制主动轮询上游;调用方完成 LINK 绑定后仍应调用 refresh,EMAIL 流程则提交 verify。 + +### 完整绑定响应示例 + +**LINK 查单:重新进入时读取已保存链接** + +`GET /openapi/v1/orders/{id}` + +按 callerOrderId 精确查单返回相同结构。GET 的 data 直接是订单,不套 order;这里不会请求上游。 + +``` +{ + "data": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 3, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**LINK 查单:链接已过期,使用原单恢复** + +`GET /openapi/v1/orders/{id}` + +链接过期后不返回旧 URL,保留签发与到期时间,并提供 bindingActionEndpoint;只读查询不会调用渠道生成新链接。 + +``` +{ + "data": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 3, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": true, + "bindingActionEndpoint": "/openapi/v1/orders/33333333-3333-4333-8333-333333333333/binding/action", + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**LINK 有效期内再次获取:复用原链接** + +`POST /openapi/v1/orders/{id}/binding/action` + +3 分钟内复用同一链接、签发时间和到期时间,不请求渠道;订单版本不会因为读取缓存而增加。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 3, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**LINK 到期后恢复绑定动作成功** + +`POST /openapi/v1/orders/{id}/binding/action` + +链接已到期后用户主动调用才重新获取上游绑定地址;等待中的并发请求通过 bindingActionInProgress 表示。示例 URL 是不可用于真实绑定的占位地址。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 4, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:03:02.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:06:02.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:03:02.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:06:02.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**LINK 刷新:角色匹配,开始排队** + +`POST /openapi/v1/orders/{id}/binding/refresh` + +binding.matched=true 后订单进入 QUEUED;仍须等待发货结果。非等待绑定状态不再返回可使用的绑定 URL。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 6, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "QUEUED", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": false, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": true, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": "123456789", + "roleName": "示例角色", + "roleArea": "示例区服" + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": "2026-08-24T12:02:00.000Z", + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "QUEUED", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "QUEUED", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "QUEUED", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "binding": { + "bindingRequired": false, + "matched": true, + "binding": { + "userBound": true, + "roleId": "123456789", + "roleName": "示例角色", + "roleArea": "示例区服" + } + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**LINK 刷新:角色不匹配,继续等待绑定** + +`POST /openapi/v1/orders/{id}/binding/refresh` + +HTTP 成功但 binding.matched=false;展示实际返回的角色并提示换绑,不得当作发货成功。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 4, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": "https://binding.example/game/link-demo", + "bindingUrlIssuedAt": "2026-08-24T12:00:01.000Z", + "bindingUrlExpiresAt": "2026-08-24T12:03:01.000Z", + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": "987654321", + "roleName": "其他角色", + "roleArea": "示例区服" + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "binding": { + "bindingRequired": true, + "matched": false, + "binding": { + "userBound": true, + "roleId": "987654321", + "roleName": "其他角色", + "roleArea": "示例区服" + } + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**EMAIL 查单:已发送验证码与重发冷却** + +`GET /openapi/v1/orders/{id}` + +resendAvailableAt 是当前持久化发送 / 验证状态推导的最早业务重试时间,不代表请求必能通过全部 HTTP 限流。 + +``` +{ + "data": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 3, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "EMAIL_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "EMAIL_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": true, + "emailCodeSentAt": "2026-08-24T12:00:01.000Z", + "resendAvailableAt": "2026-08-24T12:01:01.000Z", + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**EMAIL 获取 / 恢复动作成功** + +`POST /openapi/v1/orders/{id}/binding/action` + +已发送时返回已有动作;首次发送未完成时可以恢复。明确重发仍使用 email/resend。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 3, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "EMAIL_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "EMAIL_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": true, + "emailCodeSentAt": "2026-08-24T12:00:01.000Z", + "resendAvailableAt": "2026-08-24T12:01:01.000Z", + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "bindingAction": { + "bindingRequired": true, + "bindingType": "EMAIL_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": true, + "emailCodeSentAt": "2026-08-24T12:00:01.000Z", + "resendAvailableAt": "2026-08-24T12:01:01.000Z", + "allocationInProgress": false, + "bindingActionInProgress": false + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**EMAIL 验证成功且角色匹配** + +`POST /openapi/v1/orders/{id}/email/verify` + +请求体 { "code": "123456" } 仅为格式示例,应填写实际收到的验证码;成功后进入 QUEUED。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 6, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "EMAIL_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "QUEUED", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": false, + "bindingType": "EMAIL_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": true, + "emailCodeSentAt": "2026-08-24T12:00:01.000Z", + "resendAvailableAt": "2026-08-24T12:01:01.000Z", + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": true, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": "123456789", + "roleName": "示例角色", + "roleArea": "示例区服" + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": "2026-08-24T12:02:00.000Z", + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "QUEUED", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "QUEUED", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "QUEUED", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "binding": { + "bindingRequired": false, + "matched": true, + "binding": { + "userBound": true, + "roleId": "123456789", + "roleName": "示例角色", + "roleArea": "示例区服" + } + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**EMAIL 重发成功** + +`POST /openapi/v1/orders/{id}/email/resend` + +data.binding 返回发送操作结果;最新冷却绝对时间位于 data.order.bindingAction.resendAvailableAt。 + +``` +{ + "data": { + "order": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 4, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "EMAIL_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "4500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "4500", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 3, + "failureCodes": [], + "status": "WAITING_BINDING", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": true, + "bindingType": "EMAIL_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": true, + "emailCodeSentAt": "2026-08-24T12:02:00.000Z", + "resendAvailableAt": "2026-08-24T12:03:00.000Z", + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "ACTIVE", + "verified": false, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": null, + "roleName": null, + "roleArea": null + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": null, + "completedAt": null, + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 2, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "2000", + "orderedAmount": "2000", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2000", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 2, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + }, + { + "sequence": 1, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "productId": "22222222-2222-4222-8222-222222222222", + "productName": "示例商品 B", + "quantity": 1, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "2500", + "grandTotal": "2500", + "orderedAmount": "2500", + "settledAmount": "0", + "releasedAmount": "0", + "heldAmount": "2500", + "feeAmount": "0", + "status": "QUEUED", + "succeededQuantity": 0, + "failedQuantity": 0, + "releasedQuantity": 0, + "pendingQuantity": 1, + "failureCodes": [], + "units": [ + { + "sequence": 0, + "status": "WAITING_BINDING", + "supplierState": null, + "failureCode": null, + "failureReason": null, + "completedAt": null + } + ] + } + ] + }, + "binding": { + "sent": true, + "inProgress": false, + "sentAt": "2026-08-24T12:02:00.000Z" + } + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +**EMAIL 业务冷却尚未结束** + +``` +{ + "statusCode": 409, + "error": "ConflictException", + "message": "验证码发送过于频繁,请60秒后再试", + "code": "EMAIL_CODE_RATE_LIMITED", + "requestId": "44444444-4444-4444-8444-444444444444", + "timestamp": "2026-08-24T12:02:30.000Z", + "path": "/openapi/v1/orders/33333333-3333-4333-8333-333333333333/email/resend" +} +``` + +**绑定窗口已过期** + +``` +{ + "statusCode": 409, + "error": "ConflictException", + "message": "绑定已超时", + "code": "BINDING_TIMEOUT", + "requestId": "44444444-4444-4444-8444-444444444444", + "timestamp": "2026-08-24T12:30:01.000Z", + "path": "/openapi/v1/orders/33333333-3333-4333-8333-333333333333/binding/refresh" +} +``` + +## 订单状态与部分成功 + +| 订单状态 | 含义 | +| --- | --- | +| `WAITING_BINDING` | 等待 LINK 或 EMAIL 绑定完成,最长 30 分钟 | +| `QUEUED` | 绑定已验证,等待发货 Worker 领取 | +| `PROCESSING` | 至少一件商品正在渠道处理 | +| `RECONCILING` | 渠道提交结果未知,正在按 2 / 4 / 8 / 16 / 32 秒进行五次对账 | +| `REVIEW_REQUIRED` | 渠道记录无法唯一确认,需要管理员人工对账 | +| `SUCCEEDED` | 全部单件发货成功并已结算 | +| `PARTIAL_SUCCESS` | 部分成功;成功部分正常结算,其余已失败或释放 | +| `FAILED` | 没有单件成功,失败或未发部分已按规则释放 | +| `CANCELLED` | 调用方取消或绑定超时自动取消 | + +**以逐件结果为最终依据** + +订单及每个 item 都包含 `quantity`、`succeededQuantity`、`failedQuantity`、`releasedQuantity`、`pendingQuantity` 和去重后的 `failureCodes`;商品行另有 units。不要把 HTTP 下单成功理解为发货成功。 + +**部分成功不会整单回滚** + +成功单件正常结算;明确失败或被停止的未发单件释放冻结金额并恢复库存。当前不支持退款。 + +### 数量互斥与守恒 + +四类结果数量互斥:一件商品只能计入成功、明确失败、未执行释放、尚未确定中的一类。`failedQuantity` 不包含 `releasedQuantity`;但这两类对应的资金都会计入 `releasedAmount`。 + +整单和每个商品行均满足 `quantity = succeededQuantity + failedQuantity + releasedQuantity + pendingQuantity`。订单各数量等于商品行对应字段之和;终态 `pendingQuantity=0`。`failureCodes` 是诊断码集合,不是件数,不能靠码的个数计算失败数量。 + +| 逐件状态 units\[\].status | 唯一归属字段 | 含义 | +| --- | --- | --- | +| `WAITING_BINDING` | `pendingQuantity` | 尚未完成绑定 | +| `QUEUED` | `pendingQuantity` | 已排队,尚未提交渠道 | +| `SUBMITTING` | `pendingQuantity` | 已越过提交边界,渠道是否受理可能尚未明确 | +| `SUBMITTED` | `pendingQuantity` | 已取得渠道发货单号,等待结果 | +| `PROCESSING` | `pendingQuantity` | 渠道正在处理 | +| `RECONCILING` | `pendingQuantity` | 结果未知,正在查询渠道记录核实 | +| `REVIEW_REQUIRED` | `pendingQuantity` | 等待管理员人工对账;不能视为失败或补发 | +| `SUCCEEDED` | `succeededQuantity` | 已确认成功并结算 | +| `FAILED` | `failedQuantity` | 已明确失败且资金和额度已释放;也可能是在提交前预检中明确失败 | +| `STOPPED` | `releasedQuantity` | 尚未执行的单件被停止,资金和额度已释放 | +| `CANCELLED` | `releasedQuantity` | 尚未执行的单件被取消,资金和额度已释放 | + +**部分成功完整查单:成功 1 件、明确失败 1 件、停止释放 1 件** + +原价合计 3000 分,结算 1000 分,退回 2000 分;最多可考虑补发 2 件,但需先处理失败原因并重新通过角色额度校验。 + +``` +{ + "data": { + "id": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "orderVersion": 9, + "roleId": "123456789", + "categoryId": "55555555-5555-4555-8555-555555555555", + "deliveryType": "LINK_BINDING", + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "orderedAmount": "3000", + "settledAmount": "1000", + "releasedAmount": "2000", + "heldAmount": "0", + "feeAmount": "0", + "quantity": 3, + "succeededQuantity": 1, + "failedQuantity": 1, + "releasedQuantity": 1, + "pendingQuantity": 0, + "failureCodes": [ + "SKU_STOPPED_AFTER_FAILURE", + "SUPPLIER_REJECTED" + ], + "status": "PARTIAL_SUCCESS", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingAction": { + "bindingRequired": false, + "bindingType": "LINK_BINDING", + "expiresAt": "2026-08-24T12:30:00.000Z", + "bindingDeadline": "2026-08-24T12:30:00.000Z", + "bindingUrl": null, + "bindingUrlIssuedAt": null, + "bindingUrlExpiresAt": null, + "bindingUrlExpired": false, + "bindingActionEndpoint": null, + "emailCodeSent": false, + "emailCodeSentAt": null, + "resendAvailableAt": null, + "allocationInProgress": false, + "bindingActionInProgress": false + }, + "binding": { + "status": "QUARANTINED", + "verified": true, + "expiresAt": "2026-08-24T12:30:00.000Z", + "roleId": "123456789", + "roleName": "示例角色", + "roleArea": "示例区服" + }, + "failureCode": null, + "failureReason": null, + "review": null, + "createdAt": "2026-08-24T12:00:00.000Z", + "startedAt": "2026-08-24T12:02:00.000Z", + "completedAt": "2026-08-24T12:10:00.000Z", + "items": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "productId": "11111111-1111-4111-8111-111111111111", + "productName": "示例商品", + "quantity": 3, + "currency": { + "code": "CREDIT", + "name": "积分", + "scale": 0, + "cnyCentRate": 1 + }, + "effectiveUnitPrice": "1000", + "grandTotal": "3000", + "orderedAmount": "3000", + "settledAmount": "1000", + "releasedAmount": "2000", + "heldAmount": "0", + "feeAmount": "0", + "status": "PARTIAL_SUCCESS", + "succeededQuantity": 1, + "failedQuantity": 1, + "releasedQuantity": 1, + "pendingQuantity": 0, + "failureCodes": [ + "SKU_STOPPED_AFTER_FAILURE", + "SUPPLIER_REJECTED" + ], + "units": [ + { + "sequence": 0, + "status": "SUCCEEDED", + "supplierState": 4, + "failureCode": null, + "failureReason": null, + "completedAt": "2026-08-24T12:09:00.000Z" + }, + { + "sequence": 1, + "status": "FAILED", + "supplierState": null, + "failureCode": "SUPPLIER_REJECTED", + "failureReason": "渠道明确拒绝该件", + "completedAt": "2026-08-24T12:10:00.000Z" + }, + { + "sequence": 2, + "status": "STOPPED", + "supplierState": null, + "failureCode": "SKU_STOPPED_AFTER_FAILURE", + "failureReason": "同商品前件失败,停止尚未执行的单件", + "completedAt": "2026-08-24T12:10:00.000Z" + } + ] + } + ] + }, + "requestId": "44444444-4444-4444-8444-444444444444" +} +``` + +### PARTIAL\_SUCCESS 后如何补发 + +- 只能对原订单中已明确失败或已释放的数量补发;先根据 `failureCodes` 处理原因,再创建一张使用**新 `callerOrderId`**的新订单。 +- 原订单已是终态,不能再整单取消,也不能在原单上追加数量或由调用方改写结果。 +- 角色累计发货上限计入已成功数量和结果尚未明确的预占数量;已明确失败或释放的数量不再占用,新单仍会在事务内重新校验。 + +**REVIEW\_REQUIRED 由平台管理员处理** + +调用方不需要、也不能自行选择渠道记录。平台管理员登录 `/open-platform` 后,通过“开放订单”人工对账;运营目标为 15 分钟内响应、2 小时内完成处理,但这是人工服务目标,不是自动完成或确定时限承诺。订单详情的 `review.enteredAt`、`review.updatedAt` 和 `review.expectedResolutionAt` 分别表示首次进入时间、最近更新时间和目标完成时间,离开人工状态后仍保留供追溯。处理期间不会自动重发、判定成功或退款,请通过 Webhook 和查单接口继续跟踪。 + +**开放 API 订单终态自动退回手机号** + +`SUCCEEDED`、`PARTIAL_SUCCESS`、`FAILED` 或 `CANCELLED` 终态会自动退回开放 API 订单占用的手机号,先进入隔离池,默认 10 分钟后复查绑定信息再进入后续冷却或可用流程。同分类最后绑定角色一致的正常退还账号,指定角色申请可提前复用;使用中、有在途订单、存在异常或正在复核的账号不可抢占,复用后仍需实时核对当前绑定。这不跳过申请配额,也不清除普通分配的冷却。`RECONCILING` 和 `REVIEW_REQUIRED` 不是终态,不会提前退回。这不改变网页端手动发货“用户手动退还或等租期到期”的原有规则。 + +## 接口清单、查询、取消与账号释放 + +所有路径均需签名。点击路径旁的复制按钮,可直接复制完整接口路径。 + +**查单与回调共用 orderVersion** + +订单响应直接返回 `orderVersion`,它与该订单 Webhook 顶层版本同源。低版本结果丢弃,高版本按实际提供字段更新;相同版本的查单可以补全金额、绑定和逐件结果。Webhook 是事件摘要,字段随事件类型变化,不能用它覆盖完整订单对象或把缺失数量默认成 0。 + +绑定信息是该订单的持久化快照,不会随账号退回后被其他人使用而变成别人的角色。GET 只读;动作恢复需显式调用 `binding/action`。旧版客户端发现响应缺少 `orderVersion` 时不能按 0 合并,应升级契约处理并重新查询。 + +### 查询当前应用所属用户有权查看的 LINK / EMAIL 商品 + +GET`/openapi/v1/products` + +**请求输入** + +categoryId?、keyword?、page?、pageSize? + +**响应内容** + +商品分页列表、库存、角色累计发货上限、发货方式和当前用户成交单价 + +### 查询当前可见且可售的单个商品 + +GET`/openapi/v1/products/{id}` + +**请求输入** + +路径参数 id(UUID v4) + +**响应内容** + +与商品列表单行相同的完整投影,含 updatedAt / productVersion + +### 查询应用所属用户的共享钱包余额 + +GET`/openapi/v1/wallets` + +**请求输入** + +无 + +**响应内容** + +可用、冻结、合计余额及币种口径 + +### 查询当前应用订单产生的资金流水 + +GET`/openapi/v1/wallet-transactions` + +**请求输入** + +effect?、currencyCode?、startedAt?、endedAt?、cursor?、pageSize? + +**响应内容** + +HOLD / SETTLE / RELEASE 及可用、冻结余额变化 + +### 幂等创建聚合订单;完成绑定后自动开始逐件发货 + +POST`/openapi/v1/orders` + +**请求输入** + +callerOrderId、roleId、items\[{ productId, quantity }\] + +**响应内容** + +order、bindingAction、idempotentReplay + +### 查询单笔订单、绑定信息、商品汇总和逐件结果 + +GET`/openapi/v1/orders/{id}` + +**请求输入** + +路径参数 id(UUID v4) + +**响应内容** + +含 orderVersion、完整数量/金额/逐件结果和只读 bindingAction 的订单快照;不会调用上游或发送验证码 + +### 按调用方订单号精确查单,用于下单超时补偿 + +GET`/openapi/v1/orders/by-caller-order-id` + +**请求输入** + +查询参数 callerOrderId(1–128 字符) + +**响应内容** + +不受 90 天列表窗口限制的单笔订单快照 + +### 查询当前应用最近最多 90 天的订单记录 + +GET`/openapi/v1/orders` + +**请求输入** + +startedAt?、endedAt?、callerOrderId?、page?、pageSize? + +**响应内容** + +订单分页列表 + +### 重新进入时显式获取或恢复原订单绑定动作 + +POST`/openapi/v1/orders/{id}/binding/action` + +**请求输入** + +无请求体;与 binding/refresh 共用绑定刷新额度 + +**响应内容** + +order、bindingAction;可能调用上游生成链接或恢复邮箱动作,不创建新订单 + +### LINK 用户绑定后刷新角色信息;匹配成功即进入发货 + +POST`/openapi/v1/orders/{id}/binding/refresh` + +**请求输入** + +无请求体 + +**响应内容** + +order、binding + +### 提交 EMAIL 绑定验证码;匹配成功即进入发货 + +POST`/openapi/v1/orders/{id}/email/verify` + +**请求输入** + +{ code: string },4 至 8 位数字 + +**响应内容** + +order、binding + +### 在允许的状态和频率内重新发送 EMAIL 绑定验证码 + +POST`/openapi/v1/orders/{id}/email/resend` + +**请求输入** + +无请求体 + +**响应内容** + +order、binding + +### 所有单件均未越过渠道提交边界时取消整单 + +POST`/openapi/v1/orders/{id}/cancel` + +**请求输入** + +无请求体 + +**响应内容** + +取消后的订单快照 + +### 订单终态后幂等确认退回(终态已会自动退回) + +POST`/openapi/v1/orders/{id}/account/release` + +**请求输入** + +无请求体 + +**响应内容** + +order、lease + +- 订单列表的 `startedAt` / `endedAt` 使用 ISO 8601;结束时间必须晚于开始时间,单次跨度不超过 90 天。 +- 列表不传时间时默认查询截至当前时间的最近 90 天;`pageSize` 最大 100。按 `callerOrderId` 精确查单不受该窗口限制。 +- 只要任意单件越过渠道提交边界,整单取消就会返回 `ORDER_NOT_CANCELLABLE`。 +- 开放 API 订单终态已会自动退回手机号并默认进入 10 分钟隔离复查;同分类原角色符合安全条件时可提前申请复用,普通分配仍等待复核与冷却。`account/release` 是幂等兜底和主动确认接口,重复调用不会重复退回。 + +## Webhook 事件与验签 + +### 投递约定 + +- 回调地址必须为公网 HTTPS,系统会拒绝本地、私网、保留地址和不安全重定向。 +- Webhook Secret 与 API SecretKey 是两套独立密钥,首次配置或轮换时同样只显示一次,不能混用。 +- 采用至少一次投递;接收方必须以 `eventId` 建立唯一键去重,共享一个回调入口的多应用再以 `applicationId` 路由到对应鉴权。 +- 验签时要求请求头与请求体的 `eventId` 一致,并拒绝超出本地允许时钟窗口的时间戳。 +- 订单事件的 `orderVersion` 从 1 开始、按订单单调递增,与查单 `data.orderVersion` 共用。低版本丢弃,高版本更新已提供字段,相同版本查单可补全细节;回调摘要不能整单覆盖。不同订单之间不保证顺序;`WEBHOOK_TEST` 的版本为 `null`。 +- `schemaVersion` 表示回调请求体结构版本;当前为 `1.0`。数量字段与整单/商品行 `failureCodes` 可用于提醒和可补发判断,最终仍以查单结果为准。 +- 验签成功并持久化事件后快速返回任意 2xx;非 2xx、网络错误或超时会重试。 +- 单次请求体不超过 256 KiB,Content-Type 为 `application/json; charset=utf-8`,绝对超时为 10 秒;回调响应体请保持在 64 KiB 内。 +- 最多投递 8 次(首次 + 7 次自动重试),失败后依次等待 1 分钟、5 分钟、15 分钟、1 小时、3 小时、6 小时、12 小时;耗尽后进入 `DEAD`,可在开放平台人工重放。 +- Webhook 只负责通知,订单查询接口是断点恢复和漏通知补偿依据。 + +### 回调请求头 + +**`X-OpenAPI-Application-Id`** + +当前事件所属开放应用,与请求体 applicationId 一致 + +**`X-OpenAPI-Event-Id`** + +固定事件 ID,同时用于幂等去重 + +**`X-OpenAPI-Schema-Version`** + +回调请求体结构版本,当前为 1.0 + +**`X-OpenAPI-Timestamp`** + +本次投递的 Unix 秒级时间戳 + +**`X-OpenAPI-Signature`** + +`v1=` + HMAC-SHA256 hex + +`timestamp + "." + eventId + "." + rawBody` + +签名对象是原始 UTF-8 请求体字节,不是解析后再序列化的 JSON;签名请求头固定以 `v1=` 开头。 + +LINK 显式刷新或 EMAIL 验证取得绑定快照时发送 `BINDING_UPDATED`;角色匹配并进入 `QUEUED` 时发送 `BINDING_VERIFIED`。当前没有后台主动轮询绑定变化,调用方完成绑定后仍需执行 refresh 或 verify。 + +| 事件 | 触发时机 | +| --- | --- | +| `ORDER_CREATED` | 订单已受理 | +| `BINDING_UPDATED` | 显式刷新或 EMAIL 验证后获得了新的绑定快照 | +| `BINDING_VERIFIED` | 最新角色与订单 roleId 匹配,订单进入发货队列 | +| `UNIT_SUCCEEDED` | 单件发货成功且整单尚未结束 | +| `UNIT_FAILED` | 单件发货失败且整单尚未结束 | +| `ORDER_COMPLETED` | 整单到达成功、部分成功或失败终态 | +| `ORDER_REVIEW_REQUIRED` | 订单需要管理员人工对账 | +| `ORDER_RECONCILIATION_RESUMED` | 管理员完成选择后恢复自动处理 | +| `BINDING_TIMEOUT` | 30 分钟内未完成绑定,订单已自动取消 | +| `ORDER_CANCELLED` | 订单被调用方取消 | + +**事件请求体** + +``` +{ + "schemaVersion": "1.0", + "applicationId": "00000000-0000-4000-8000-000000000301", + "eventId": "66666666-6666-4666-8666-666666666666", + "orderVersion": 7, + "type": "ORDER_COMPLETED", + "occurredAt": "2026-08-24T12:10:00.000Z", + "data": { + "orderId": "33333333-3333-4333-8333-333333333333", + "orderNo": "O...", + "callerOrderId": "merchant-order-20260824-0001", + "status": "PARTIAL_SUCCESS", + "succeededQuantity": 2, + "failedQuantity": 1, + "releasedQuantity": 0, + "pendingQuantity": 0, + "failureCodes": [ + "SUPPLIER_REJECTED" + ], + "changedUnitId": "99999999-9999-4999-8999-999999999999", + "items": [ + { + "productId": "11111111-1111-4111-8111-111111111111", + "status": "SUCCEEDED", + "quantity": 2, + "succeededQuantity": 2, + "failedQuantity": 0, + "releasedQuantity": 0, + "failureCodes": [] + }, + { + "productId": "22222222-2222-4222-8222-222222222222", + "status": "FAILED", + "quantity": 1, + "succeededQuantity": 0, + "failedQuantity": 1, + "releasedQuantity": 0, + "failureCodes": [ + "SUPPLIER_REJECTED" + ] + } + ] + } +} +``` + +**Node.js 验签** + +``` +import { createHmac, timingSafeEqual } from 'node:crypto' + +// rawBody 必须是在 JSON 解析前保留的原始请求体字节。 +function verifyWebhook({ rawBody, timestamp, eventId, applicationId, schemaVersion, signature, secret, now = Date.now() }) { + if (!/^(0|[1-9]\d{0,12})$/.test(timestamp)) return false + if (Math.abs(Number(timestamp) * 1000 - now) > 5 * 60 * 1000) return false + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/.test(eventId)) return false + const payload = Buffer.concat([Buffer.from(timestamp + '.' + eventId + '.', 'utf8'), rawBody]) + const expected = createHmac('sha256', secret).update(payload).digest('hex') + const actual = signature.startsWith('v1=') ? signature.slice(3) : '' + const verified = /^[a-fA-F0-9]{64}$/.test(actual) && + timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(actual, 'hex')) + if (!verified) return false + // 只在验签后解析 JSON,并校验请求头和请求体身份一致。 + try { + const body = JSON.parse(rawBody.toString('utf8')) + return body.eventId === eventId && body.applicationId === applicationId && + body.schemaVersion === schemaVersion + } catch { return false } +} + +// 验签成功后以 eventId 做唯一键;重复事件直接返回 2xx。 +// 查单与回调共用 orderVersion:低版本丢弃,高版本合并已提供字段。 +// 相同版本的查单可补全逐件/金额/绑定细节;回调摘要不能覆盖完整订单对象。 +// WEBHOOK_TEST 的 orderVersion 为 null,不参与订单版本比较。 +``` + +## 错误码与安全重试 + +| HTTP | code | 含义 | 处理建议 | +| --- | --- | --- | --- | +| 400 | `HTTP_400` | 请求体、查询参数、UUID 或字段格式未通过校验 | 按 message 修正参数后,使用新的签名信息重试 | +| 400 | `DUPLICATE_PRODUCT_LINE` | 同一 SKU 在 items 中重复出现 | 修正参数后重试 | +| 400 | `MIXED_CATEGORY_OR_DELIVERY_TYPE` | 订单混入不同分类或不同发货方式 | 拆分订单 | +| 400 | `MIXED_CURRENCY` | 一张聚合订单混入了不同币种 | 按币种拆分订单 | +| 400 | `INVALID_QUERY_RANGE` | 订单或资金流水查询时间无效,或单次跨度超过 90 天 | 修正时间范围 | +| 400 | `INVALID_CURSOR` | 资金流水翻页游标无效 | 丢弃自行构造的游标,从首页重新查询 | +| 401 | `OPENAPI_CREDENTIAL_INVALID` | 凭据、时间戳、Nonce、签名或写请求 IP 校验失败 | 检查接入配置;服务端刻意不暴露具体鉴权原因 | +| 403 | `OPENAPI_PERMISSION_REVOKED` | 应用所属用户已不再具备 openapi:use | 联系管理员恢复权限 | +| 403 | `PRODUCT_ACCESS_DENIED` | 商品可以查看,但当前用户没有购买权限 | 只对 accessMode=PURCHASE 的商品下单 | +| 404 | `OPENAPI_PRODUCT_NOT_FOUND / OPENAPI_ORDER_NOT_FOUND / HTTP_404` | 商品或当前应用下的订单不存在 | 检查资源 ID | +| 503 | `OPENAPI_PRODUCT_MEDIA_URL_INVALID` | 商品图片未能安全解析为绝对 HTTPS 地址 | 停止展示该商品并联系平台管理员 | +| 409 | `CALLER_ORDER_ID_CONFLICT` | 相同 callerOrderId 已用于不同的请求内容 | 禁止自动换号重发。先按原 callerOrderId 精确查单,核对角色、商品、数量和原业务请求;确认属于一次新的业务发货后才使用新单号 | +| 409 | `ROLE_DELIVERY_LIMIT_EXCEEDED` | 同一 SKU + 经验证角色 ID 的全系统累计发货额度不足 | 减少发货数量或更换真实角色;更换手机号无法规避 | +| 409 | `PRODUCT_NOT_ON_SALE / PRODUCT_MEDIA_NOT_READY / CURRENCY_DISABLED` | 商品或币种在下单前已变为不可用 | 重新查询 products,不要对原商品盲目重试 | +| 409 | `PRODUCT_CHANNEL_MAPPING_UNAVAILABLE / PRODUCT_SUPPLY_PRICE_UNSAFE / OPENAPI_CDK_UNSUPPORTED / PRICE_OVERFLOW` | 商品渠道映射或供货价不可用,或商品不在 V1 支持范围 | 停止自动重试并联系平台管理员 | +| 409 | `INSUFFICIENT_STOCK / INSUFFICIENT_BALANCE` | 库存或应用所属用户余额不足 | 补足后使用同一 callerOrderId 重试 | +| 409 | `NO_VIRTUAL_ACCOUNT_AVAILABLE / VIRTUAL_ACCOUNT_LIMIT_REACHED` | 当前没有可租用账号,或用户在该分类达到租用上限 | 本单会取消;容量恢复后使用新 callerOrderId 下单 | +| 409 | `BINDING_TIMEOUT / BINDING_ROLE_MISMATCH` | 绑定已超时,或最新绑定角色与订单 roleId 不一致 | 超时后新建订单;不一致时重新绑定 | +| 409 | `EMAIL_CODE_RATE_LIMITED` | EMAIL 验证码发送过于频繁 | 查询原单的 bindingAction.resendAvailableAt,等冷却结束后再重发;额外 HTTP 限流仍须遵循 429 的 Retry-After | +| 409 | `EMAIL_CODE_SEND_IN_PROGRESS / EMAIL_VERIFICATION_IN_PROGRESS` | 同一订单正在发送验证码或提交验证,两个渠道操作会互斥 | 先查询订单状态;仍等待绑定时,等处理窗口结束后再重试,期间不要盲目 resend | +| 409 | `EMAIL_BINDING_RESULT_UNKNOWN` | 验证码提交结果不确定,系统未盲目重复绑定 | 查询订单;必要时调用 resend | +| 409 | `BINDING_LEASE_NOT_READY / OPENAPI_ORDER_BINDING_STATE_CHANGED / OPENAPI_ORDER_NOT_WAITING_BINDING` | 手机号分配未完成、已失效,或订单绑定状态已发生并发变化 | 先查询订单;仍在等待绑定时再幂等重试原操作 | +| 409 | `OPENAPI_ORDER_SUPPLIER_SNAPSHOT_INCONSISTENT / OPENAPI_ORDER_SNAPSHOT_SUPPLIER_UNAVAILABLE / OPENAPI_BINDING_LEASE_SUPPLIER_MISMATCH` | 订单不可变渠道快照或已分配账号与渠道不一致 | 停止自动重试,保留 requestId 并联系平台管理员 | +| 409 | `OPENAPI_LEASE_RELEASE_NOT_ALLOWED` | 订单尚未进入终态,不能调用幂等退回确认 | 等订单进入终态;系统会自动退回,release 仅作兜底 | +| 409 | `ORDER_NOT_CANCELLABLE` | 至少一件商品已经越过渠道提交边界或订单已终态 | 不可整单取消,继续查询最终结果 | +| 429 | `OPENAPI_RATE_LIMITED` | 超过应用级、同用户共享、全平台、单订单操作、下单件数或预认证防护阈值,或触发短时突发保护 | 优先遵循 Retry-After;未提供时指数退避并加入抖动 | +| 5xx | `ORDER_FINANCIAL_STATE_INCONSISTENT / ORDER_CURRENCY_INCONSISTENT / ORDER_QUANTITY_STATE_INCONSISTENT / ORDER_BINDING_STATE_INCONSISTENT / HTTP_500` | 订单金额、币种、数量或绑定快照无法确认时返回 503,其他服务异常可能返回 500;响应包含 requestId | 保留原 callerOrderId 查询,保存 requestId 联系平台;不能将异常当作未发货、盲目补发或自动换新单号 | + +**错误响应结构** + +``` +{ + "statusCode": 409, + "error": "ConflictException", + "message": "调用方订单号已用于不同请求", + "code": "CALLER_ORDER_ID_CONFLICT", + "requestId": "44444444-4444-4444-8444-444444444444", + "timestamp": "2026-08-24T12:00:00.000Z", + "path": "/openapi/v1/orders" +} +``` + +所有 HTTP 429 响应都会携带 Retry-After(秒)。必须先按该值等待,再使用新 Timestamp / Nonce / Signature 重试;不要立即并发放大。 + +不要因网络超时自动生成新订单号。保留 callerOrderId,先查询订单状态;确认未受理时再使用相同参数幂等重试。 + +## 完整 Node.js 签名与请求示例 + +示例要求 Node.js 18+,仅应运行在可信服务端。请通过安全的环境变量或密钥管理服务注入密钥。 + +**联调环境与覆盖范围** + +当前尚未提供独立隔离沙箱的地址和联调账号。固定验签向量只用于离线算法回归,开放平台的测试回调只验证连通性与签名,均不能替代免绑定、LINK / EMAIL、部分成功、超时已受理、重复 / 乱序回调和人工对账的完整联调。 + +独立沙箱及可控制的异常场景需另行建设,请联系平台协调联调安排;实际游戏绑定、真实邮箱验证码和渠道最终结果还需供应商测试资源验证。本文的业务响应均为结构示例,不是已完成真实联调的证明。 + +**Node.js 18+** + +``` +import { createHash, createHmac, randomBytes } from 'node:crypto' + +const baseUrl = 'https://shop.cloudtentacles.com' +const accessKey = process.env.OPENAPI_ACCESS_KEY +const secretKey = process.env.OPENAPI_SECRET_KEY +if (!accessKey || !secretKey) throw new Error('缺少开放平台密钥') +const method = 'POST' +const path = '/openapi/v1/orders' +const query = new URLSearchParams() +const rawBody = JSON.stringify({ callerOrderId: 'demo-001', roleId: '123456789', + items: [{ productId: '11111111-1111-4111-8111-111111111111', quantity: 1 }] }) +const timestamp = String(Math.floor(Date.now() / 1000)) +const nonce = randomBytes(18).toString('base64url') + +// RFC 3986 编码后再按编码后的键和值排序,重复键不能合并。 +const encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, + (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase()) +const canonicalQuery = [...query.entries()] + .map(([key, value]) => [encode(key), encode(value)]) + .sort(([ak, av], [bk, bv]) => ak < bk ? -1 : ak > bk ? 1 : av < bv ? -1 : av > bv ? 1 : 0) + .map(([key, value]) => key + '=' + value).join('&') +const bodyHash = createHash('sha256').update(Buffer.from(rawBody, 'utf8')).digest('hex') +const signingString = [method, path, canonicalQuery, bodyHash, timestamp, nonce].join('\n') +const signature = createHmac('sha256', secretKey).update(signingString).digest('hex') + +const response = await fetch(baseUrl + path, { + method, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'x-openapi-access-key': accessKey, + 'x-openapi-timestamp': timestamp, + 'x-openapi-nonce': nonce, + 'x-openapi-signature': signature, + }, + body: rawBody, +}) +console.log(response.status, await response.json()) +``` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c876d5..1152a9b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import MerchantCenter from './pages/MerchantCenter' import MerchantRecharge from './pages/MerchantRecharge' import AlertSettings from './pages/AlertSettings' import PlatformMerchants from './pages/PlatformMerchants' +import PlatformProducts from './pages/PlatformProducts' import PlatformUsers from './pages/PlatformUsers' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { @@ -86,6 +87,14 @@ function AppRoutes() { } /> + + + + } + /> } /> } /> request.post(`/platform/recharge/applications/${id}/review`, data).then((r) => r.data.data as RechargeApplication), } + +export const lewanApi = { + listProducts: (params?: { page?: number; size?: number; keyword?: string }) => + request.get('/platform/lewan/products', { params }).then((r) => r.data.data as LewanProductListResult), + syncCatalog: (data: SyncLewanProductInput) => + request.post('/platform/lewan/sync-catalog', data).then((r) => r.data.data as ProductCatalogItem), + updateRouting: (id: number, data: UpdateProductRoutingInput) => + request.put(`/platform/products/${id}/routing`, data).then((r) => r.data.data as { updated: boolean }), +} + diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 7912f54..9c65701 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -66,7 +66,10 @@ const adminSections: SidebarSection[] = [ key: 'products', label: '商品管理', icon: , - children: [{ key: 'merchant-products', label: '商品列表', path: '/merchant-products' }], + children: [ + { key: 'merchant-products', label: '商品列表', path: '/merchant-products' }, + { key: 'platform-products', label: '渠道与路由配置', path: '/platform-products' }, + ], }, { key: 'shops', diff --git a/frontend/src/pages/PlatformProducts.tsx b/frontend/src/pages/PlatformProducts.tsx new file mode 100644 index 0000000..dd1d30e --- /dev/null +++ b/frontend/src/pages/PlatformProducts.tsx @@ -0,0 +1,919 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Alert, + Badge, + Button, + Card, + Col, + Divider, + Drawer, + Form, + Input, + InputNumber, + Modal, + Radio, + Row, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import { + BranchesOutlined, + CheckCircleOutlined, + CloudDownloadOutlined, + DesktopOutlined, + ImportOutlined, + LinkOutlined, + ReloadOutlined, + SearchOutlined, + SettingOutlined, + SwapOutlined, +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import { lewanApi, platformApi } from '../api' +import type { + LewanProductItem, + ProductCatalogItem, + ProductRoutingConfig, + SyncLewanProductInput, + UpdateProductRoutingInput, +} from '../types' +import { PageHeader } from '../components/PageHeader' + +const { Text, Paragraph } = Typography + +export default function PlatformProducts() { + const [catalog, setCatalog] = useState([]) + const [loading, setLoading] = useState(false) + const [searchKeyword, setSearchKeyword] = useState('') + const [filterMode, setFilterMode] = useState('all') + const [filterCategory, setFilterCategory] = useState('all') + + // 乐玩上游商品抽屉状态 + const [lewanDrawerOpen, setLewanDrawerOpen] = useState(false) + const [lewanLoading, setLewanLoading] = useState(false) + const [lewanProducts, setLewanProducts] = useState([]) + const [lewanPage, setLewanPage] = useState(1) + const [lewanTotal, setLewanTotal] = useState(0) + const [lewanKeyword, setLewanKeyword] = useState('') + + // 路由配置 Modal 状态 + const [configModalOpen, setConfigModalOpen] = useState(false) + const [configuringItem, setConfiguringItem] = useState(null) + const [configSubmitting, setConfigSubmitting] = useState(false) + const [configForm] = Form.useForm() + + // 乐玩商品导入 Modal 状态 + const [syncModalOpen, setSyncModalOpen] = useState(false) + const [syncSubmitting, setSyncSubmitting] = useState(false) + const [syncForm] = Form.useForm() + + // 加载自营平台商品目录 + const loadCatalog = useCallback(async () => { + setLoading(true) + try { + const list = await platformApi.productCatalog() + setCatalog(list || []) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载商品目录失败') + } finally { + setLoading(false) + } + }, []) + + // 加载乐玩商品列表 + const loadLewanProducts = useCallback(async (page = 1, keyword = '') => { + setLewanLoading(true) + try { + const res = await lewanApi.listProducts({ page, size: 20, keyword }) + setLewanProducts(res.items || []) + setLewanTotal(res.total || 0) + setLewanPage(page) + } catch (e) { + message.error(e instanceof Error ? e.message : '拉取乐玩商品列表失败,请确认乐玩配置与网络连接') + } finally { + setLewanLoading(false) + } + }, []) + + useEffect(() => { + loadCatalog() + }, [loadCatalog]) + + // 打开乐玩商品库抽屉 + const handleOpenLewanDrawer = () => { + setLewanDrawerOpen(true) + loadLewanProducts(1, lewanKeyword) + } + + // 统计指标 + const metrics = useMemo(() => { + let priorityCount = 0 + let lewanOnlyCount = 0 + let dlcOnlyCount = 0 + + catalog.forEach((item) => { + const mode = item.routing?.routing_mode || 'dlc' + if (mode === 'priority') priorityCount++ + else if (mode === 'lewan') lewanOnlyCount++ + else dlcOnlyCount++ + }) + + return { + total: catalog.length, + priorityCount, + lewanOnlyCount, + dlcOnlyCount, + } + }, [catalog]) + + // 目录所有分类 + const categories = useMemo(() => { + const set = new Set() + catalog.forEach((item) => { + if (item.category) set.add(item.category) + }) + return Array.from(set) + }, [catalog]) + + // 过滤后的目录列表 + const filteredCatalog = useMemo(() => { + return catalog.filter((item) => { + const matchKeyword = + !searchKeyword || + item.display_name.toLowerCase().includes(searchKeyword.toLowerCase()) || + item.sku.toLowerCase().includes(searchKeyword.toLowerCase()) || + String(item.id).includes(searchKeyword) + + const mode = item.routing?.routing_mode || 'dlc' + const matchMode = + filterMode === 'all' || + (filterMode === 'priority' && mode === 'priority') || + (filterMode === 'lewan' && mode === 'lewan') || + (filterMode === 'dlc' && mode === 'dlc') + + const matchCategory = filterCategory === 'all' || item.category === filterCategory + + return matchKeyword && matchMode && matchCategory + }) + }, [catalog, searchKeyword, filterMode, filterCategory]) + + // 打开渠道与优先级配置 Modal + const openConfigModal = (record: ProductCatalogItem) => { + setConfiguringItem(record) + const routing = record.routing || { routing_mode: 'dlc' } + const mode = routing.routing_mode || 'dlc' + const priorityList = routing.priority || ['lewan', 'dlc'] + const priorityOrder = priorityList[0] === 'dlc' ? 'dlc_first' : 'lewan_first' + + configForm.setFieldsValue({ + routing_mode: mode, + priority_order: priorityOrder, + dlc_good_id: routing.dlc_good_id || '', + lewan_product_id: routing.lewan_product_id || '', + lewan_name: routing.lewan_name || '', + delivery_type: routing.delivery_type || 'LINK_BINDING', + }) + setConfigModalOpen(true) + } + + // 提交渠道与优先级配置 + const submitConfig = async () => { + if (!configuringItem) return + const values = await configForm.validateFields() + setConfigSubmitting(true) + try { + const priority = + values.routing_mode === 'priority' + ? values.priority_order === 'dlc_first' + ? ['dlc', 'lewan'] + : ['lewan', 'dlc'] + : values.routing_mode === 'lewan' + ? ['lewan'] + : ['dlc'] + + const payload: UpdateProductRoutingInput = { + routing_mode: values.routing_mode, + priority, + dlc_good_id: values.dlc_good_id?.trim() || '', + lewan_product_id: values.lewan_product_id?.trim() || '', + lewan_name: values.lewan_name?.trim() || '', + delivery_type: values.delivery_type || 'LINK_BINDING', + } + + await lewanApi.updateRouting(configuringItem.id, payload) + message.success(`商品 [${configuringItem.display_name}] 履约策略更新成功`) + setConfigModalOpen(false) + loadCatalog() + } catch (e) { + message.error(e instanceof Error ? e.message : '更新配置失败') + } finally { + setConfigSubmitting(false) + } + } + + // 打开将乐玩商品导入为自营目录的 Modal + const openSyncModal = (lewanProduct: LewanProductItem) => { + syncForm.resetFields() + const unitPrice = Number.parseFloat(lewanProduct.effectiveUnitPrice || '') + syncForm.setFieldsValue({ + lewan_product_id: lewanProduct.id, + name: lewanProduct.name, + category: lewanProduct.categoryName || '未分类', + sku: `LEWAN_${lewanProduct.id.slice(0, 8).toUpperCase()}`, + price_yuan: Number.isFinite(unitPrice) ? unitPrice.toFixed(2) : 0, + delivery_type: lewanProduct.deliveryType || 'LINK_BINDING', + }) + setSyncModalOpen(true) + } + + // 提交乐玩商品导入 + const submitSync = async () => { + const values = await syncForm.validateFields() + setSyncSubmitting(true) + try { + const priceCents = Math.round(Number(values.price_yuan || 0) * 100) + const payload: SyncLewanProductInput = { + lewan_product_id: values.lewan_product_id, + name: values.name, + category: values.category, + sku: values.sku, + price: priceCents, + delivery_type: values.delivery_type, + } + await lewanApi.syncCatalog(payload) + message.success(`乐玩商品 [${values.name}] 已成功同步导入至自营目录!`) + setSyncModalOpen(false) + loadCatalog() + } catch (e) { + message.error(e instanceof Error ? e.message : '同步导入失败') + } finally { + setSyncSubmitting(false) + } + } + + // 快捷从乐玩列表快速关联到当前配置的商品 + const quickAttachLewanProduct = (p: LewanProductItem) => { + configForm.setFieldsValue({ + lewan_product_id: p.id, + lewan_name: p.name, + delivery_type: p.deliveryType || 'LINK_BINDING', + }) + message.success(`已选定乐玩商品: ${p.name}`) + } + + // 渲染策略 Tag + const renderRoutingTag = (routing?: ProductRoutingConfig) => { + const mode = routing?.routing_mode || 'dlc' + if (mode === 'priority') { + const isLewanFirst = !routing?.priority || routing.priority[0] === 'lewan' + return isLewanFirst ? ( + } style={{ fontWeight: 600, padding: '3px 8px' }}> + 双平台: 乐玩优先 ➔ DLC兜底 + + ) : ( + } style={{ fontWeight: 600, padding: '3px 8px' }}> + 双平台: DLC优先 ➔ 乐玩兜底 + + ) + } + if (mode === 'lewan') { + return ( + } style={{ fontWeight: 600, padding: '3px 8px' }}> + 仅走乐玩平台 + + ) + } + return ( + } style={{ fontWeight: 600, padding: '3px 8px' }}> + 仅走第一平台 (DLC) + + ) + } + + // 目录表格列 + const columns: ColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + width: 70, + align: 'center', + }, + { + title: 'SKU / 编码', + dataIndex: 'sku', + width: 190, + render: (v) => {v}, + }, + { + title: '商品名称', + dataIndex: 'display_name', + width: 220, + ellipsis: true, + render: (v, r) => ( +
+ {v} + {r.category && ( + + {r.category} + + )} +
+ ), + }, + { + title: '参考售价 / 成本', + key: 'price', + width: 170, + render: (_, r) => ( + + + 售价: {(r.price_amount / 100).toFixed(2)} 元 + + + 成本: {(r.cost_amount / 100).toFixed(2)} 元 + + + ), + }, + { + title: '履约与优先级策略', + key: 'routing_mode', + width: 230, + render: (_, r) => ( +
+ {renderRoutingTag(r.routing)} +
+ {r.status === 'active' ? ( + + ) : ( + + )} +
+
+ ), + }, + { + title: '渠道映射详情', + key: 'mapping', + width: 280, + render: (_, r) => { + const routing = r.routing + const lewanId = routing?.lewan_product_id + const dlcId = routing?.dlc_good_id + return ( + +
+ 第一平台 (DLC): + {dlcId ? ( + {dlcId} + ) : ( + 默认按 SKU 匹配 + )} +
+
+ 乐玩平台: + {lewanId ? ( + + + + {routing.lewan_name || lewanId.slice(0, 8) + '...'} + + + {routing.delivery_type && ( + + {routing.delivery_type === 'LINK_BINDING' ? '授权链接' : routing.delivery_type} + + )} + + ) : ( + 未关联乐玩商品 + )} +
+
+ ) + }, + }, + { + title: '操作', + key: 'action', + width: 140, + fixed: 'right', + render: (_, r) => ( + + ), + }, + ] + + // 乐玩商品表格列 + const lewanColumns: ColumnsType = [ + { + title: '商品名称', + dataIndex: 'name', + render: (v, r) => ( +
+ {v} + {r.categoryName && {r.categoryName}} +
+ + {r.id} + +
+
+ ), + }, + { + title: '供货价', + dataIndex: 'effectiveUnitPrice', + width: 100, + render: (v?: string) => { + const n = Number.parseFloat(v || '') + return Number.isFinite(n) ? `${n.toFixed(2)} 元` : '-' + }, + }, + { + title: '库存', + dataIndex: 'stock', + width: 80, + render: (v?: number) => (v == null ? '-' : v), + }, + { + title: '交付形式', + dataIndex: 'deliveryType', + width: 120, + render: (v) => + v === 'LINK_BINDING' ? ( + }>链接绑定 + ) : v === 'EMAIL_BINDING' ? ( + 邮箱绑定(未接入) + ) : ( + {v || '标准'} + ), + }, + { + title: '操作', + key: 'action', + width: 180, + render: (_, r) => ( + + + {configModalOpen && ( + + + + )} + + ), + }, + ] + + return ( +
+ + + + + } + /> + + {/* 顶部指标卡片 */} + + + +
+ 自营目录商品数 +
+ +
+
+
{metrics.total}
+
+ 基础平台预设商品库 +
+
+ + + +
+ 双平台优先策略 +
+ +
+
+
{metrics.priorityCount}
+
+ 乐玩与DLC高可用智能切换 +
+
+ + + +
+ 仅乐玩平台履约 +
+ +
+
+
{metrics.lewanOnlyCount}
+
+ 专属乐玩上游独占商品 +
+
+ + + +
+ 仅第一平台 (DLC) +
+ +
+
+
{metrics.dlcOnlyCount}
+
+ 原有第一平台商品通道 +
+
+ +
+ + {/* 筛选与搜索工具条 */} + + + + + } + value={searchKeyword} + onChange={(e) => setSearchKeyword(e.target.value)} + style={{ width: 240 }} + allowClear + /> + ({ value: c, label: c })), + ]} + /> + )} + + + + + 共检索到 {filteredCatalog.length} 件商品 + + + + + + {/* 主商品表格 */} + `共 ${total} 件自营商品`, + }} + /> + + {/* 渠道与优先级配置 Modal */} + + + 配置履约渠道与优先级 + {configuringItem && ( + {configuringItem.display_name} ({configuringItem.sku}) + )} + + } + open={configModalOpen} + onOk={submitConfig} + onCancel={() => setConfigModalOpen(false)} + confirmLoading={configSubmitting} + destroyOnClose + width={720} + okText="保存配置" + cancelText="取消" + > +
+ 履约模式 (Routing Mode)} + rules={[{ required: true }]} + > + + + +
+ 双平台优先策略 (Priority Routing) +
+ 两平台都有货时首选指定优先级平台发货;当首选平台无库存或异常时,自动平滑兜底至备用平台。 +
+
+
+ +
+ 仅乐玩开放平台 (Lewan Only) +
+ 所有订单均通过乐玩开放平台发货(需配置乐玩商品ID)。 +
+
+
+ +
+ 仅第一平台 (DLC Only) +
+ 所有订单均通过第一平台 (DLC / Unified BFF) 进行履约。 +
+
+
+
+
+
+ + {/* 当选择 priority 时展示优先级顺序选择 */} + prev.routing_mode !== cur.routing_mode} + > + {({ getFieldValue }) => + getFieldValue('routing_mode') === 'priority' ? ( + 双平台优先级顺序} + rules={[{ required: true }]} + > + + + 乐玩优先 ➔ 第一平台 (DLC) 兜底 (推荐: 乐玩直接授权,成本透明) + + + 第一平台 (DLC) 优先 ➔ 乐玩兜底 (优先走老平台) + + + + ) : null + } + + + + 渠道商品映射信息 + + + + + +
+ + + 若该商品参与乐玩履约,请输入乐玩后台的商品 UUID + + + + } + > + + + + + + + + + + + + + + + + + + {/* 乐玩商品库抽屉 */} + + + 乐玩开放平台商品库 (实时拉取) + + } + placement="right" + width={780} + open={lewanDrawerOpen} + onClose={() => setLewanDrawerOpen(false)} + extra={ + + } + > + + 此处实时连通乐玩开放平台 API 拉取最新可供货商品。您可以点击「导入为自营」直接生成自营商品,或在配置弹窗中直接关联该商品。 + + + + } + value={lewanKeyword} + onChange={(e) => setLewanKeyword(e.target.value)} + onPressEnter={() => loadLewanProducts(1, lewanKeyword)} + style={{ width: 320 }} + allowClear + /> + + + +
loadLewanProducts(page, lewanKeyword), + showTotal: (total) => `共 ${total} 件乐玩上游商品`, + }} + /> + + + {/* 导入乐玩商品至自营目录 Modal */} + setSyncModalOpen(false)} + confirmLoading={syncSubmitting} + destroyOnClose + width={560} + okText="确认导入" + cancelText="取消" + > +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + +