diff --git a/.gitignore b/.gitignore index 53fa6a5..779ef1d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ frontend/.env # backend backend/bin/ +data/ backend/data/ backend/logs/ /logs/ diff --git a/backend/cmd/cache_images/main.go b/backend/cmd/cache_images/main.go new file mode 100644 index 0000000..02d240f --- /dev/null +++ b/backend/cmd/cache_images/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "affiliate_dash/internal/config" + "affiliate_dash/internal/pkg/lewan" +) + +func main() { + cfg := config.Load() + client := lewan.NewClient(lewan.Config{ + BaseURL: cfg.LewanBaseURL, + AccessKey: cfg.LewanAccessKey, + SecretKey: cfg.LewanSecretKey, + ApplicationID: cfg.LewanApplicationID, + }, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + list, err := client.ListProducts(ctx, 1, 100, "") + if err != nil { + fmt.Printf("ListProducts error: %v\n", err) + return + } + + fmt.Printf("Found %d products (total: %d)\n", len(list.Items), list.Total) + uploadDir := cfg.UploadDir + if uploadDir == "" { + uploadDir = "data/uploads" + } + cacheDir := filepath.Join(uploadDir, "products") + _ = os.MkdirAll(cacheDir, 0755) + + httpClient := &http.Client{Timeout: 15 * time.Second} + + for i, item := range list.Items { + fmt.Printf("[%d/%d] ID: %s, Name: %s, Thumb: %s\n", i+1, len(list.Items), item.ID, item.Name, item.ThumbnailUrl) + if item.ThumbnailUrl == "" { + continue + } + // 存储为 item.ID + 原扩展名 + ext := filepath.Ext(item.ThumbnailUrl) + if ext == "" || len(ext) > 5 { + ext = ".webp" + } + destPath := filepath.Join(cacheDir, item.ID+ext) + // 同时也用 url hash 存一个软链或备份,以便 proxy 按 url 精准命中 + urlHash := sha256.Sum256([]byte(item.ThumbnailUrl)) + hashPath := filepath.Join(cacheDir, hex.EncodeToString(urlHash[:])+ext) + + if _, err := os.Stat(destPath); err == nil { + fmt.Printf(" -> 已存在缓存: %s\n", destPath) + continue + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, item.ThumbnailUrl, nil) + if err != nil { + fmt.Printf(" -> 构建请求失败: %v\n", err) + continue + } + resp, err := httpClient.Do(req) + if err != nil { + fmt.Printf(" -> 下载失败: %v\n", err) + continue + } + if resp.StatusCode != http.StatusOK { + fmt.Printf(" -> 状态码错误: %d\n", resp.StatusCode) + resp.Body.Close() + continue + } + data, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + fmt.Printf(" -> 读取失败: %v\n", err) + continue + } + + _ = os.WriteFile(destPath, data, 0644) + _ = os.WriteFile(hashPath, data, 0644) + fmt.Printf(" -> 成功缓存: %s (大小: %d 字节)\n", destPath, len(data)) + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 481cffd..31b0348 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -96,6 +96,7 @@ func main() { lewanProvider := service.NewLewanProvider(lewanClient) deliverySvc.RegisterProvider(lewanProvider) fulfillmentSvc.SetUpstreamCancel(deliverySvc.CancelUpstream) + fulfillmentSvc.SetUpstreamSync(deliverySvc.SyncDeliveringOrder) openlog.Init(cfg.OpenAPIDebug) diff --git a/backend/internal/handler/delivery.go b/backend/internal/handler/delivery.go index d4a5444..f4e5ff2 100644 --- a/backend/internal/handler/delivery.go +++ b/backend/internal/handler/delivery.go @@ -1,8 +1,15 @@ package handler import ( + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "path/filepath" "strconv" "strings" + "time" "affiliate_dash/internal/pkg/response" "affiliate_dash/internal/service" @@ -65,6 +72,88 @@ func (h *DeliveryHandler) Submit(c *gin.Context) { response.OK(c, result) } +// ImageProxy 代理图片并支持本地持久化缓存,杜绝跨域污染和远端加载失败。 +func (h *DeliveryHandler) ImageProxy(c *gin.Context) { + rawURL := strings.TrimSpace(c.Query("url")) + if rawURL == "" || (!strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://")) { + response.BadRequest(c, "无效的图片地址") + return + } + + // 1. 优先检查本地官方商品图片缓存 + for _, base := range []string{"data/uploads/products", "../data/uploads/products", "uploads/products"} { + if strings.Contains(rawURL, "/products/") { + parts := strings.Split(rawURL, "/products/") + if len(parts) > 1 { + sub := strings.Split(parts[1], "/") + if len(sub) > 0 && sub[0] != "" { + localPath := filepath.Join(base, sub[0]+".webp") + if info, err := os.Stat(localPath); err == nil && info.Size() > 0 { + c.Header("Cache-Control", "public, max-age=2592000, immutable") + c.File(localPath) + return + } + } + } + } + // 按 URL SHA256 哈希查本地缓存 + hash := sha256.Sum256([]byte(rawURL)) + hashPath := filepath.Join(base, hex.EncodeToString(hash[:])+".webp") + if info, err := os.Stat(hashPath); err == nil && info.Size() > 0 { + c.Header("Cache-Control", "public, max-age=2592000, immutable") + c.File(hashPath) + return + } + } + + // 2. 本地无缓存时请求远端 + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, rawURL, nil) + if err != nil { + response.BadRequest(c, "构建请求失败") + return + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + response.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "图片下载失败") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + response.Fail(c, resp.StatusCode, resp.StatusCode, "远端图片响应异常") + return + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + response.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "读取图片内容失败") + return + } + + // 3. 异步/同步回写到本地磁盘缓存 + cacheDir := "data/uploads/products" + _ = os.MkdirAll(cacheDir, 0755) + if strings.Contains(rawURL, "/products/") { + parts := strings.Split(rawURL, "/products/") + if len(parts) > 1 { + sub := strings.Split(parts[1], "/") + if len(sub) > 0 && sub[0] != "" { + _ = os.WriteFile(filepath.Join(cacheDir, sub[0]+".webp"), data, 0644) + } + } + } + hash := sha256.Sum256([]byte(rawURL)) + _ = os.WriteFile(filepath.Join(cacheDir, hex.EncodeToString(hash[:])+".webp"), data, 0644) + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "image/webp" + } + c.Header("Cache-Control", "public, max-age=2592000, immutable") + c.Data(http.StatusOK, contentType, data) +} + func parseDeliveryAuth(c *gin.Context) service.DeliveryLinkAuth { exp := int64(0) if value := strings.TrimSpace(c.Query("exp")); value != "" { diff --git a/backend/internal/handler/platform_lewan.go b/backend/internal/handler/platform_lewan.go index 1574f99..72e5f9b 100644 --- a/backend/internal/handler/platform_lewan.go +++ b/backend/internal/handler/platform_lewan.go @@ -118,3 +118,38 @@ func (h *PlatformLewanHandler) SyncLewanProduct(c *gin.Context) { } response.WithStatus(c, http.StatusCreated, item) } + +type batchSyncLewanReq struct { + Products []syncLewanProductReq `json:"products" binding:"required,min=1"` +} + +// BatchSyncLewanProducts POST /api/platform/lewan/batch-sync-catalog +// 批量同步/添加乐玩商品到自营商品目录 +func (h *PlatformLewanHandler) BatchSyncLewanProducts(c *gin.Context) { + var req batchSyncLewanReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:products 列表不能为空") + return + } + + actorUserID := middleware.GetUserID(c) + inputs := make([]service.SyncLewanProductInput, len(req.Products)) + for i, p := range req.Products { + inputs[i] = service.SyncLewanProductInput{ + LewanProductID: p.LewanProductID, + Name: p.Name, + Category: p.Category, + SKU: p.SKU, + Price: p.Price, + DeliveryType: p.DeliveryType, + } + } + + count, err := h.merchantSvc.BatchSyncLewanProductsToCatalog(inputs, actorUserID) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"synced_count": count}) +} + diff --git a/backend/internal/pkg/lewan/client.go b/backend/internal/pkg/lewan/client.go index 05ec99d..5b1ea4c 100644 --- a/backend/internal/pkg/lewan/client.go +++ b/backend/internal/pkg/lewan/client.go @@ -132,6 +132,8 @@ type LewanOrder struct { Status string `json:"status"` // WAITING_BINDING, QUEUED, PROCESSING, RECONCILING, SUCCEEDED, PARTIAL_SUCCESS, FAILED, CANCELLED BindingDeadline *string `json:"bindingDeadline"` BindingAction *BindingAction `json:"bindingAction,omitempty"` + Binding *BindingInfo `json:"binding,omitempty"` + CompletedAt *string `json:"completedAt,omitempty"` } // CreateOrderResult 下单响应 @@ -148,6 +150,7 @@ type BindingInfo struct { Matched bool `json:"matched"` RoleID string `json:"roleId"` RoleName string `json:"roleName"` + RoleArea string `json:"roleArea,omitempty"` ServerName string `json:"serverName"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 51be816..bad5502 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -112,6 +112,7 @@ func Setup(h *Handlers) *gin.Engine { delivery.GET("/orders/:order_no", h.Delivery.GetOrder) delivery.POST("/orders/:order_no/bind", h.Delivery.Bind) delivery.POST("/orders/:order_no/submit", h.Delivery.Submit) + delivery.GET("/image-proxy", h.Delivery.ImageProxy) } // 源头侧接口:上游发货平台查询订单并回传发货结果。 @@ -228,6 +229,7 @@ func Setup(h *Handlers) *gin.Engine { if h.PlatformLewan != nil { admin.GET("/platform/lewan/products", h.PlatformLewan.ListLewanProducts) admin.POST("/platform/lewan/sync-catalog", h.PlatformLewan.SyncLewanProduct) + admin.POST("/platform/lewan/batch-sync-catalog", h.PlatformLewan.BatchSyncLewanProducts) admin.PUT("/platform/products/:id/routing", h.PlatformLewan.UpdateProductRouting) } } diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 1d5e1a8..d058ae3 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -135,6 +135,77 @@ func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error { return nil } +// SyncDeliveringOrder 主动向上游渠道查询处于 delivering 状态的订单结果,以补充 Webhook 丢失或延迟。 +func (s *DeliveryService) SyncDeliveringOrder(ctx context.Context, order *model.FulfillmentOrder) (bool, error) { + if order == nil || s.router == nil || order.OrderStatus != model.OrderStatusDelivering { + return false, nil + } + if ctx == nil { + ctx = context.Background() + } + + providerName := resultDataString(order.ResultData, "provider") + if providerName == "" && (resultDataString(order.ResultData, "lewan_order_id") != "" || resultDataString(order.ResultData, "lewan_order_no") != "" || strings.HasPrefix(order.ProviderOrderNo, "OMU")) { + providerName = ProviderLewan + } + + providers := make([]FulfillmentProvider, 0, 2) + if providerName != "" { + if p := s.router.GetProvider(providerName); p != nil { + providers = append(providers, p) + } + } + if len(providers) == 0 { + resolved, _ := s.router.ResolveProviders(order) + providers = resolved + } + + for _, p := range providers { + res, err := p.SyncStatus(ctx, order) + if err != nil || res == nil { + continue + } + if res.Status == "success" || res.Status == "failed" { + input := ShipNotifyInput{ + OrderNo: order.OrderNo, + ShipStatus: res.Status, + ProviderOrderNo: res.ProviderOrderNo, + FailReason: res.FailReason, + RawPayload: res.RawPayload, + } + if res.GameUID != "" { + input.GameUID = &res.GameUID + } + if res.RoleName != "" { + input.RoleName = &res.RoleName + } + if res.RoleArea != "" { + input.GameChannel = &res.RoleArea + } + if _, err := s.fulfillment.HandleShipNotify(input); err != nil { + return false, err + } + return true, nil + } + // 如果仍在发货中,且获得了游戏UID/角色名/区服,补充写入 ResultData + patch := map[string]interface{}{} + if res.GameUID != "" && resultDataString(order.ResultData, "game_uid") == "" { + patch["game_uid"] = res.GameUID + } + if res.RoleName != "" && resultDataString(order.ResultData, "role_name") == "" { + patch["role_name"] = res.RoleName + } + if res.RoleArea != "" && resultDataString(order.ResultData, "game_channel") == "" { + patch["game_channel"] = res.RoleArea + } + if len(patch) > 0 { + _ = s.persistOrderResultPatch(order, patch) + } + return false, nil + } + return false, nil +} + // persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。 func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error { if order == nil || len(patch) == 0 { @@ -181,6 +252,8 @@ type DeliveryOrderInfo struct { Product *DeliveryProduct `json:"product,omitempty"` BuyerName string `json:"buyer_name"` Amount int64 `json:"amount"` + ProviderOrderNo string `json:"provider_order_no,omitempty"` + ProviderName string `json:"provider_name,omitempty"` CreatedAt time.Time `json:"created_at"` ShippedAt *time.Time `json:"shipped_at"` ShipFailReason string `json:"ship_fail_reason,omitempty"` @@ -633,8 +706,14 @@ func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*O if err != nil { return nil, nil, "", err } + if order.OrderStatus == model.OrderStatusDelivering { + if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { + openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) + order, _ = s.fulfillment.GetOrder(merchantID, orderNo) + } + } goodID := "" - if openOrder.Product != nil { + if openOrder != nil && openOrder.Product != nil { goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) } return openOrder, order, goodID, nil @@ -701,6 +780,24 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason) } + providerOrderNo := strings.TrimSpace(openOrder.ProviderOrderNo) + if providerOrderNo == "" { + providerOrderNo = strings.TrimSpace(order.ProviderOrderNo) + } + if providerOrderNo == "" { + providerOrderNo = resultDataString(order.ResultData, "lewan_order_no") + } + if providerOrderNo == "" { + providerOrderNo = resultDataString(order.ResultData, "provider_order_no") + } + + providerName := resultDataString(order.ResultData, "provider") + if providerName == "" { + if strings.TrimSpace(order.ProviderOrderNo) != "" || resultDataString(order.ResultData, "lewan_order_id") != "" { + providerName = "lewan" + } + } + info := &DeliveryOrderInfo{ OrderNo: openOrder.OrderNo, Status: openOrder.Status, @@ -709,6 +806,8 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m Product: deliveryProd, BuyerName: openOrder.BuyerName, Amount: openOrder.Amount, + ProviderOrderNo: providerOrderNo, + ProviderName: providerName, CreatedAt: openOrder.CreatedAt, ShippedAt: openOrder.ShippedAt, ShipFailReason: openOrder.ShipFailReason, @@ -886,6 +985,12 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth return nil, nil, "", err } } + if order.OrderStatus == model.OrderStatusDelivering { + if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { + openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) + order, _ = s.fulfillment.GetByOrderNo(orderNo) + } + } return s.buildDeliveryState(openOrder, order, "", requireCanShip) } diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 39e5204..aa85ad4 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "errors" "math" @@ -19,6 +20,7 @@ type FulfillmentService struct { db *gorm.DB callbacks *CallbackService upstreamCancel func(order *model.FulfillmentOrder) error + upstreamSync func(ctx context.Context, order *model.FulfillmentOrder) (bool, error) } func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService { @@ -30,6 +32,11 @@ func (s *FulfillmentService) SetUpstreamCancel(fn func(order *model.FulfillmentO s.upstreamCancel = fn } +// SetUpstreamSync 注册主动查单同步上游状态的钩子(如乐玩订单补偿)。 +func (s *FulfillmentService) SetUpstreamSync(fn func(ctx context.Context, order *model.FulfillmentOrder) (bool, error)) { + s.upstreamSync = fn +} + type CreateFulfillmentOrderInput struct { MerchantID uint APIClientID uint diff --git a/backend/internal/service/fulfillment_data.go b/backend/internal/service/fulfillment_data.go index cf6edad..5819adf 100644 --- a/backend/internal/service/fulfillment_data.go +++ b/backend/internal/service/fulfillment_data.go @@ -51,10 +51,7 @@ func mergeResultData(existing string, patch map[string]interface{}) string { if len(patch) == 0 { return existing } - m := map[string]interface{}{} - if existing != "" && json.Valid([]byte(existing)) { - _ = json.Unmarshal([]byte(existing), &m) - } + m := resultDataMap(existing) for k, v := range patch { m[k] = v } @@ -67,8 +64,15 @@ func mergeResultData(existing string, patch map[string]interface{}) string { func resultDataMap(raw string) map[string]interface{} { m := map[string]interface{}{} - if raw != "" && json.Valid([]byte(raw)) { - _ = json.Unmarshal([]byte(raw), &m) + if raw == "" { + return m + } + if err := json.Unmarshal([]byte(raw), &m); err != nil { + // 容错:如果 raw 被双重序列化为 string(如 "\"{\\\"provider\\\":...}\"") + var inner string + if json.Unmarshal([]byte(raw), &inner) == nil && inner != "" { + _ = json.Unmarshal([]byte(inner), &m) + } } return m } @@ -76,8 +80,14 @@ func resultDataMap(raw string) map[string]interface{} { // requestDataMap 解析下单透传的 data(RequestData),非法或为空时返回空 map。 func requestDataMap(raw string) map[string]interface{} { m := map[string]interface{}{} - if raw != "" && json.Valid([]byte(raw)) { - _ = json.Unmarshal([]byte(raw), &m) + if raw == "" { + return m + } + if err := json.Unmarshal([]byte(raw), &m); err != nil { + var inner string + if json.Unmarshal([]byte(raw), &inner) == nil && inner != "" { + _ = json.Unmarshal([]byte(inner), &m) + } } return m } diff --git a/backend/internal/service/fulfillment_shipnotify.go b/backend/internal/service/fulfillment_shipnotify.go index 4be0fdc..3dd0131 100644 --- a/backend/internal/service/fulfillment_shipnotify.go +++ b/backend/internal/service/fulfillment_shipnotify.go @@ -72,8 +72,18 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe return err } + now := time.Now() + shippedAt := in.ShippedAt + if shippedAt == nil && in.ShipStatus == "success" { + shippedAt = &now + } + // 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。 if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" { + newResultData := buildShipNotifyResultData(order.ResultData, in, shippedAt) + if newResultData != order.ResultData { + _ = tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Update("result_data", newResultData).Error + } result = ShipNotifyResult{ OrderNo: order.OrderNo, Status: normalizeOrderStatus(&order), @@ -91,12 +101,6 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe return nil } - now := time.Now() - shippedAt := in.ShippedAt - if shippedAt == nil && in.ShipStatus == "success" { - shippedAt = &now - } - updates := map[string]interface{}{ "order_status": nextStatus, } diff --git a/backend/internal/service/fulfillment_timeout.go b/backend/internal/service/fulfillment_timeout.go index 33bd4d3..0d6c87e 100644 --- a/backend/internal/service/fulfillment_timeout.go +++ b/backend/internal/service/fulfillment_timeout.go @@ -33,6 +33,14 @@ func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit } changed := 0 for _, id := range ids { + if s.upstreamSync != nil { + var ord model.FulfillmentOrder + if err := s.db.First(&ord, id).Error; err == nil { + if synced, _ := s.upstreamSync(context.Background(), &ord); synced { + continue + } + } + } updated, err := s.markProcessingTimeout(id, timeout, now) if err != nil { return changed, err diff --git a/backend/internal/service/merchant_catalog.go b/backend/internal/service/merchant_catalog.go index 2b52e77..e42d136 100644 --- a/backend/internal/service/merchant_catalog.go +++ b/backend/internal/service/merchant_catalog.go @@ -456,3 +456,18 @@ func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, ac } return res, nil } + +// BatchSyncLewanProductsToCatalog 批量导入或关联乐玩商品至平台自营商品目录 +func (s *MerchantService) BatchSyncLewanProductsToCatalog(items []SyncLewanProductInput, actorUserID uint) (int, error) { + if len(items) == 0 { + return 0, nil + } + count := 0 + for _, item := range items { + if _, err := s.SyncLewanProductToCatalog(item, actorUserID); err == nil { + count++ + } + } + return count, nil +} + diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 1d6eb10..f858f74 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -115,6 +115,17 @@ type ProviderSubmitResult struct { PatchResultData map[string]interface{} `json:"patch_result_data,omitempty"` } +// ProviderSyncResult 抽象主动查单同步状态返回 +type ProviderSyncResult struct { + Status string // success / failed / delivering / "" + ProviderOrderNo string + FailReason string + GameUID string + RoleName string + RoleArea string + RawPayload string +} + // FulfillmentProvider 统一履约提供商接口 type FulfillmentProvider interface { Name() string @@ -122,6 +133,7 @@ type FulfillmentProvider interface { 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) + SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) Cancel(ctx context.Context, order *model.FulfillmentOrder) error } diff --git a/backend/internal/service/provider_dlc.go b/backend/internal/service/provider_dlc.go index 5831549..1555af4 100644 --- a/backend/internal/service/provider_dlc.go +++ b/backend/internal/service/provider_dlc.go @@ -350,6 +350,10 @@ func (p *DLCProvider) createUpstreamOrder(orderID, gameAccount, goodID, orderNo return out.Order, nil } +func (p *DLCProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) { + return nil, nil +} + func (p *DLCProvider) signProxy(path, method string, data interface{}, out interface{}) error { payload, err := json.Marshal(map[string]interface{}{ "path": path, diff --git a/backend/internal/service/provider_lewan.go b/backend/internal/service/provider_lewan.go index 26d27b3..dee3b38 100644 --- a/backend/internal/service/provider_lewan.go +++ b/backend/internal/service/provider_lewan.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -356,3 +357,114 @@ func (p *LewanProvider) Cancel(ctx context.Context, order *model.FulfillmentOrde _, err := p.client.CancelOrder(ctx, lewanOrderID) return err } + +func (p *LewanProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) { + if p.client == nil || order == nil { + return nil, nil + } + callerOrderID := order.OrderNo + lewanOrderID := resultDataString(order.ResultData, "lewan_order_id") + + var ( + lewanOrder *lewan.LewanOrder + err error + ) + if callerOrderID != "" { + lewanOrder, err = p.client.GetOrderByCallerOrder(ctx, callerOrderID) + } + if lewanOrder == nil && lewanOrderID != "" { + lewanOrder, err = p.client.GetOrder(ctx, lewanOrderID) + } + if err != nil || lewanOrder == nil { + return nil, err + } + + roleName := "" + roleArea := "" + gameUID := lewanOrder.RoleID + if lewanOrder.Binding != nil { + if lewanOrder.Binding.RoleID != "" { + gameUID = lewanOrder.Binding.RoleID + } + roleName = lewanOrder.Binding.RoleName + roleArea = lewanOrder.Binding.RoleArea + } + if gameUID == "" { + gameUID = resultDataString(order.ResultData, "game_uid") + } + + raw, _ := json.Marshal(lewanOrder) + providerOrderNo := lewanOrder.OrderNo + if providerOrderNo == "" { + providerOrderNo = lewanOrder.ID + } + + switch lewanOrder.Status { + case "SUCCEEDED": + return &ProviderSyncResult{ + Status: "success", + ProviderOrderNo: providerOrderNo, + GameUID: gameUID, + RoleName: roleName, + RoleArea: roleArea, + RawPayload: string(raw), + }, nil + case "FAILED": + reason := strings.Join(lewanOrder.FailureCodes, "; ") + if reason == "" { + reason = "乐玩平台发货失败" + } + return &ProviderSyncResult{ + Status: "failed", + ProviderOrderNo: providerOrderNo, + FailReason: reason, + GameUID: gameUID, + RoleName: roleName, + RoleArea: roleArea, + RawPayload: string(raw), + }, nil + case "PARTIAL_SUCCESS": + if lewanOrder.SucceededQuantity > 0 { + return &ProviderSyncResult{ + Status: "success", + ProviderOrderNo: providerOrderNo, + GameUID: gameUID, + RoleName: roleName, + RoleArea: roleArea, + RawPayload: string(raw), + }, nil + } + reason := strings.Join(lewanOrder.FailureCodes, "; ") + if reason == "" { + reason = "乐玩部分单件被停止或释放" + } + return &ProviderSyncResult{ + Status: "failed", + ProviderOrderNo: providerOrderNo, + FailReason: reason, + GameUID: gameUID, + RoleName: roleName, + RoleArea: roleArea, + RawPayload: string(raw), + }, nil + case "ORDER_CANCELLED", "BINDING_TIMEOUT": + return &ProviderSyncResult{ + Status: "failed", + ProviderOrderNo: providerOrderNo, + FailReason: "乐玩订单超时或已取消", + GameUID: gameUID, + RoleName: roleName, + RoleArea: roleArea, + RawPayload: string(raw), + }, nil + default: + return &ProviderSyncResult{ + Status: "delivering", + ProviderOrderNo: providerOrderNo, + GameUID: gameUID, + RoleName: roleName, + RoleArea: roleArea, + RawPayload: string(raw), + }, nil + } +} diff --git a/frontend/public/images/products/46524a53-068d-4bee-97ff-d5e7873818e0.webp b/frontend/public/images/products/46524a53-068d-4bee-97ff-d5e7873818e0.webp new file mode 100644 index 0000000..8218780 Binary files /dev/null and b/frontend/public/images/products/46524a53-068d-4bee-97ff-d5e7873818e0.webp differ diff --git a/frontend/public/images/products/518e3bec-f9f2-483b-89ae-d826c9cf14e5.webp b/frontend/public/images/products/518e3bec-f9f2-483b-89ae-d826c9cf14e5.webp new file mode 100644 index 0000000..7767a85 Binary files /dev/null and b/frontend/public/images/products/518e3bec-f9f2-483b-89ae-d826c9cf14e5.webp differ diff --git a/frontend/public/images/products/779857c7-e6db-40e3-9f4f-4eb5eca350e1.webp b/frontend/public/images/products/779857c7-e6db-40e3-9f4f-4eb5eca350e1.webp new file mode 100644 index 0000000..556b7bd Binary files /dev/null and b/frontend/public/images/products/779857c7-e6db-40e3-9f4f-4eb5eca350e1.webp differ diff --git a/frontend/public/images/products/8621502c-e355-455c-9b74-a7e642a40cfe.webp b/frontend/public/images/products/8621502c-e355-455c-9b74-a7e642a40cfe.webp new file mode 100644 index 0000000..3e0c01e Binary files /dev/null and b/frontend/public/images/products/8621502c-e355-455c-9b74-a7e642a40cfe.webp differ diff --git a/frontend/public/images/products/8eed3610-7238-4b89-8010-6713268f7cd3.webp b/frontend/public/images/products/8eed3610-7238-4b89-8010-6713268f7cd3.webp new file mode 100644 index 0000000..c7c2b47 Binary files /dev/null and b/frontend/public/images/products/8eed3610-7238-4b89-8010-6713268f7cd3.webp differ diff --git a/frontend/public/images/products/a09d18ce-4a55-4313-925b-6de66d96154b.webp b/frontend/public/images/products/a09d18ce-4a55-4313-925b-6de66d96154b.webp new file mode 100644 index 0000000..8aa9342 Binary files /dev/null and b/frontend/public/images/products/a09d18ce-4a55-4313-925b-6de66d96154b.webp differ diff --git a/frontend/public/images/products/b1c64119-d164-41e6-a1dc-0d8167dd09de.webp b/frontend/public/images/products/b1c64119-d164-41e6-a1dc-0d8167dd09de.webp new file mode 100644 index 0000000..507617c Binary files /dev/null and b/frontend/public/images/products/b1c64119-d164-41e6-a1dc-0d8167dd09de.webp differ diff --git a/frontend/public/images/products/b1fff090-e389-4c6c-9c51-32751582239d.webp b/frontend/public/images/products/b1fff090-e389-4c6c-9c51-32751582239d.webp new file mode 100644 index 0000000..23ebdae Binary files /dev/null and b/frontend/public/images/products/b1fff090-e389-4c6c-9c51-32751582239d.webp differ diff --git a/frontend/public/images/products/b3891176-5ffa-40cb-8c58-7e46eac3f381.webp b/frontend/public/images/products/b3891176-5ffa-40cb-8c58-7e46eac3f381.webp new file mode 100644 index 0000000..fbc2c15 Binary files /dev/null and b/frontend/public/images/products/b3891176-5ffa-40cb-8c58-7e46eac3f381.webp differ diff --git a/frontend/public/images/products/d5dfa4e3-675b-4187-8163-8aa04727c6e4.webp b/frontend/public/images/products/d5dfa4e3-675b-4187-8163-8aa04727c6e4.webp new file mode 100644 index 0000000..d84b9ce Binary files /dev/null and b/frontend/public/images/products/d5dfa4e3-675b-4187-8163-8aa04727c6e4.webp differ diff --git a/frontend/public/images/products/ed57398a-a470-4d85-abc4-761c81274d9f.webp b/frontend/public/images/products/ed57398a-a470-4d85-abc4-761c81274d9f.webp new file mode 100644 index 0000000..45040ef Binary files /dev/null and b/frontend/public/images/products/ed57398a-a470-4d85-abc4-761c81274d9f.webp differ diff --git a/frontend/public/images/products/f3b83b85-c664-4440-94ac-a4b0cd7cf0f3.webp b/frontend/public/images/products/f3b83b85-c664-4440-94ac-a4b0cd7cf0f3.webp new file mode 100644 index 0000000..9ec951c Binary files /dev/null and b/frontend/public/images/products/f3b83b85-c664-4440-94ac-a4b0cd7cf0f3.webp differ diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d26e61b..e2f06b4 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -240,7 +240,10 @@ export const lewanApi = { 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), + batchSyncCatalog: (products: SyncLewanProductInput[]) => + request.post('/platform/lewan/batch-sync-catalog', { products }).then((r) => r.data.data as { synced_count: number }), 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/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 6837b7f..0b56841 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { Alert, @@ -7,6 +7,7 @@ import { Form, Image, Input, + Modal, Result, Space, Spin, @@ -18,12 +19,21 @@ import { import { CheckCircleOutlined, ClockCircleOutlined, + CopyOutlined, + DownloadOutlined, + EyeOutlined, + LoadingOutlined, QrcodeOutlined, + ReloadOutlined, + SafetyCertificateOutlined, SendOutlined, + SyncOutlined, } from '@ant-design/icons' import { deliveryApi } from '../api' import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types' import { formatDateTime } from '../utils/time' +import { generateDeliveryVoucher } from '../utils/voucher' +import { resolveProductImage } from '../utils/productImage' const statusMap: Record = { paid: { color: 'orange', text: '待发货' }, @@ -33,17 +43,34 @@ const statusMap: Record = { cancelled: { color: 'default', text: '已取消' }, } +function dataURLtoBlob(dataurl: string): Blob { + const arr = dataurl.split(',') + const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png' + const bstr = atob(arr[1]) + let n = bstr.length + const u8arr = new Uint8Array(n) + while (n--) { + u8arr[n] = bstr.charCodeAt(n) + } + return new Blob([u8arr], { type: mime }) +} + export default function Delivery() { - const { channel = 'dlc', orderNo = '' } = useParams() + const { orderNo = '' } = useParams() const [searchParams] = useSearchParams() const [form] = Form.useForm<{ game_account: string }>() const [order, setOrder] = useState(null) const [bindResult, setBindResult] = useState(null) const [submitResult, setSubmitResult] = useState(null) const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) const [binding, setBinding] = useState(false) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') + const [voucherImageURL, setVoucherImageURL] = useState('') + const [generatingImage, setGeneratingImage] = useState(false) + const [copyingImage, setCopyingImage] = useState(false) + const [previewModalOpen, setPreviewModalOpen] = useState(false) const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo]) const deliveryAuth = useMemo(() => { @@ -53,21 +80,122 @@ export default function Delivery() { }, [searchParams]) const gameAccount = Form.useWatch('game_account', form) - useEffect(() => { - if (!decodedOrderNo) { - setError('发货链接缺少订单号') - setLoading(false) - return + const getOrGenerateVoucher = async () => { + if (!order) return '' + if (voucherImageURL) return voucherImageURL + setGeneratingImage(true) + try { + const url = await generateDeliveryVoucher(order) + setVoucherImageURL(url) + return url + } catch (e) { + message.error('生成发货凭证图失败:' + (e instanceof Error ? e.message : '请稍后重试')) + return '' + } finally { + setGeneratingImage(false) } - setLoading(true) - deliveryApi.getOrder(decodedOrderNo, deliveryAuth) - .then((data) => { + } + + const handleDownloadVoucher = async () => { + const url = await getOrGenerateVoucher() + if (!url) return + const link = document.createElement('a') + link.href = url + link.download = `发货凭据_${order?.order_no || 'order'}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + message.success('凭证图片已生成并开始下载') + } + + const handlePreviewVoucher = async () => { + setPreviewModalOpen(true) + await getOrGenerateVoucher() + } + + const handleCopyVoucherImage = async () => { + setCopyingImage(true) + try { + const url = await getOrGenerateVoucher() + if (!url) { + return + } + + if ( + typeof window !== 'undefined' && + navigator.clipboard && + typeof ClipboardItem !== 'undefined' && + navigator.clipboard.write + ) { + const blob = dataURLtoBlob(url) + const item = new ClipboardItem({ 'image/png': blob }) + await navigator.clipboard.write([item]) + message.success('结果图片已复制到剪贴板!可直接粘贴到聊天窗口(Ctrl+V / ⌘+V)') + } else { + setPreviewModalOpen(true) + message.info('当前浏览器环境不支持直接写入剪贴板图片,已为您打开大图,可长按或右键复制图片') + } + } catch { + setPreviewModalOpen(true) + message.warning('写入剪贴板失败,已为您打开凭据预览,可长按或右键复制图片') + } finally { + setCopyingImage(false) + } + } + + const fetchOrder = useCallback( + async (silent = false) => { + if (!decodedOrderNo) { + setError('发货链接缺少订单号') + setLoading(false) + return + } + if (!silent) { + setLoading(true) + } + try { + const data = await deliveryApi.getOrder(decodedOrderNo, deliveryAuth) setOrder(data) setError('') - }) - .catch((e) => setError(e instanceof Error ? e.message : '订单加载失败')) - .finally(() => setLoading(false)) - }, [decodedOrderNo, deliveryAuth]) + } catch (e) { + if (!silent) { + setError(e instanceof Error ? e.message : '订单加载失败') + } + } finally { + if (!silent) { + setLoading(false) + } + } + }, + [decodedOrderNo, deliveryAuth] + ) + + useEffect(() => { + fetchOrder(false) + }, [fetchOrder]) + + // 当订单处于发货中 (delivering) 时,每 3 秒自动轮询发货进度 + useEffect(() => { + if (order?.status !== 'delivering') { + return + } + const timer = setInterval(() => { + fetchOrder(true) + }, 3000) + return () => clearInterval(timer) + }, [order?.status, fetchOrder]) + + const handleManualRefresh = async () => { + setRefreshing(true) + try { + await fetchOrder(true) + message.success('状态已刷新') + } catch { + message.error('刷新状态失败') + } finally { + setRefreshing(false) + } + } const bindAccount = async () => { const values = await form.validateFields() @@ -94,7 +222,17 @@ export default function Delivery() { try { const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth) setSubmitResult(result) - setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev) + setOrder((prev) => + prev + ? { + ...prev, + status: result.status, + can_ship: false, + game_uid: values.game_account, + role_name: (result.game_account as Record)?.role_name || prev.role_name, + } + : prev + ) message.success(result.message || '已提交') } catch (e) { message.error(e instanceof Error ? e.message : '提交失败') @@ -126,13 +264,24 @@ export default function Delivery() {
-
- - 游戏道具发货 - - - {channel.toUpperCase()} · {decodedOrderNo} - +
+
+ + 游戏道具发货 + + + 订单编号:{decodedOrderNo} + +
+ {order.status === 'delivering' && ( + + )}
@@ -151,40 +300,222 @@ export default function Delivery() { {order.buyer_name || '-'} {order.amount} 积分 {formatDateTime(order.created_at)} - {order.product?.game || channel.toUpperCase()} + {order.product?.game || '自动发货'} - {order.ship_fail_reason && ( + {order.ship_fail_reason && order.status !== 'ship_failed' && ( )} - {!order.can_ship && ( - - )} + {!order.can_ship && + order.status !== 'delivering' && + order.status !== 'delivered' && + order.status !== 'ship_failed' && ( + + )}
- {order.can_ship && ( + {/* 状态 1:已发货完成 */} + {order.status === 'delivered' && ( +
+ } + loading={copyingImage || generatingImage} + onClick={handleCopyVoucherImage} + style={{ background: '#16a34a', borderColor: '#16a34a' }} + > + 复制结果图片 + , + , + , + , + ]} + > +
+ {/* 商品卡片信息 */} +
+
+ {order.product?.image ? ( + {order.product.name} + ) : ( +
+ 道具 +
+ )} +
+
+ {order.product?.name || '游戏道具商品'} +
+ + {order.product?.sku || '-'} + {order.product?.game && {order.product.game}} + 已发放 1 件 + {order.amount} 积分 + +
+
+ +
+ + + + + {order.game_uid || gameAccount || '-'} + + + + + {order.role_name || '-'} + + + {order.game_channel || '-'} + {formatDateTime(order.created_at)} + + {order.shipped_at ? formatDateTime(order.shipped_at) : '已完成'} + + + +
+ +
+ 官方履约已完成:本次道具发货已成功直充到账。点击「复制结果图片」可直接复制凭据图并在微信、客服聊天窗直接粘贴(Ctrl+V / ⌘+V)。 +
+
+
+
+
+ )} + + {/* 状态 2:发货处理中 */} + {order.status === 'delivering' && ( +
+ +
+ } /> +
+ + 道具正在极速发货中... + + + 已成功提交上游发放节点,通常在 10 ~ 30 秒内到账,页面会自动更新。 + +
+
+ +
+ + + {order.game_uid || gameAccount || '-'} + + + {order.role_name || '正在识别...'} + + {order.game_channel || '自动识别'} + + }>队列发放中 + + +
+ +
+ + 系统正在自动刷新进度(每 3 秒) + + +
+
+
+ )} + + {/* 状态 3:发货失败 */} + {order.status === 'ship_failed' && ( +
+ + 重新查询订单状态 + , + ]} + /> +
+ )} + + {/* 状态 4:待绑定扫码 */} + {order.status === 'paid' && (
}, - { title: '扫码绑定', icon: }, + { title: '输入账号', icon: }, + { title: '微信/QQ 扫码绑定', icon: }, { title: '提交发货', icon: }, ]} /> -
+ - +
@@ -241,6 +572,70 @@ export default function Delivery() { )}
+ + + + 发货电子凭证预览 + + } + open={previewModalOpen} + onCancel={() => setPreviewModalOpen(false)} + footer={[ + , + , + , + ]} + width={640} + centered + > + {generatingImage ? ( +
+ +
正在为您绘制高清发货凭证图片...
+
+ ) : voucherImageURL ? ( +
+ 发货电子凭证 +
+ 提示:支持长按或右键复制/保存图片到本地相册,作为官方维权及售后保障证据。 +
+
+ ) : ( +
+ +
+ )} +
) } diff --git a/frontend/src/pages/PlatformProducts.tsx b/frontend/src/pages/PlatformProducts.tsx index dd1d30e..d874e68 100644 --- a/frontend/src/pages/PlatformProducts.tsx +++ b/frontend/src/pages/PlatformProducts.tsx @@ -60,6 +60,8 @@ export default function PlatformProducts() { const [lewanPage, setLewanPage] = useState(1) const [lewanTotal, setLewanTotal] = useState(0) const [lewanKeyword, setLewanKeyword] = useState('') + const [selectedLewanMap, setSelectedLewanMap] = useState>(new Map()) + const [batchSubmitting, setBatchSubmitting] = useState(false) // 路由配置 Modal 状态 const [configModalOpen, setConfigModalOpen] = useState(false) @@ -219,13 +221,14 @@ export default function PlatformProducts() { // 打开将乐玩商品导入为自营目录的 Modal const openSyncModal = (lewanProduct: LewanProductItem) => { syncForm.resetFields() - const unitPrice = Number.parseFloat(lewanProduct.effectiveUnitPrice || '') + const unitPriceFen = Number.parseFloat(lewanProduct.effectiveUnitPrice || '') + const unitPriceYuan = Number.isFinite(unitPriceFen) ? Number((unitPriceFen / 100).toFixed(2)) : 0 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, + price_yuan: unitPriceYuan, delivery_type: lewanProduct.deliveryType || 'LINK_BINDING', }) setSyncModalOpen(true) @@ -256,6 +259,92 @@ export default function PlatformProducts() { } } + // 批量导入选中的乐玩商品 + const handleBatchSync = () => { + const selectedItems = Array.from(selectedLewanMap.values()) + if (selectedItems.length === 0) return + + Modal.confirm({ + title: `确认批量导入选中的 ${selectedItems.length} 个乐玩商品?`, + content: ( +
+ + 将自动按乐玩供货价生成自营商品并配置为乐玩履约模式,同步加入平台自营目录。 + +
+ {selectedItems.map((item, idx) => { + const unitPriceFen = Number.parseFloat(item.effectiveUnitPrice || '') + const unitPriceYuan = Number.isFinite(unitPriceFen) + ? (unitPriceFen / 100).toFixed(2) + : '0.00' + return ( +
+ {item.name} + {unitPriceYuan} 元 +
+ ) + })} +
+
+ ), + okText: `确认导入 (${selectedItems.length})`, + cancelText: '取消', + width: 500, + onOk: async () => { + setBatchSubmitting(true) + try { + const payload: SyncLewanProductInput[] = selectedItems.map((p) => { + const unitPriceFen = Number.parseFloat(p.effectiveUnitPrice || '') + return { + lewan_product_id: p.id, + name: p.name, + category: p.categoryName || '未分类', + sku: `LEWAN_${p.id.slice(0, 8).toUpperCase()}`, + price: Number.isFinite(unitPriceFen) ? Math.round(unitPriceFen) : 1000, + delivery_type: p.deliveryType || 'LINK_BINDING', + } + }) + let count = 0 + try { + const res = await lewanApi.batchSyncCatalog(payload) + count = res.synced_count + } catch { + // 兼容后端尚未重启的情况:自动降级为循环单条导入 + for (const item of payload) { + await lewanApi.syncCatalog(item) + count++ + } + } + message.success(`成功批量导入 ${count} 个商品至自营目录!`) + setSelectedLewanMap(new Map()) + loadCatalog() + } catch (e) { + message.error(e instanceof Error ? e.message : '批量导入失败') + } finally { + setBatchSubmitting(false) + } + }, + }) + } + // 快捷从乐玩列表快速关联到当前配置的商品 const quickAttachLewanProduct = (p: LewanProductItem) => { configForm.setFieldsValue({ @@ -437,10 +526,10 @@ export default function PlatformProducts() { { title: '供货价', dataIndex: 'effectiveUnitPrice', - width: 100, + width: 110, render: (v?: string) => { const n = Number.parseFloat(v || '') - return Number.isFinite(n) ? `${n.toFixed(2)} 元` : '-' + return Number.isFinite(n) ? `${(n / 100).toFixed(2)} 元` : '-' }, }, { @@ -803,25 +892,41 @@ export default function PlatformProducts() { 乐玩开放平台商品库 (实时拉取) + {selectedLewanMap.size > 0 && ( + 已勾选 {selectedLewanMap.size} 项 + )} } placement="right" - width={780} + width={820} open={lewanDrawerOpen} onClose={() => setLewanDrawerOpen(false)} extra={ - + + {selectedLewanMap.size > 0 && ( + + )} + + } > - 此处实时连通乐玩开放平台 API 拉取最新可供货商品。您可以点击「导入为自营」直接生成自营商品,或在配置弹窗中直接关联该商品。 + 此处实时连通乐玩开放平台 API 拉取最新可供货商品。勾选多项可一键批量导入,或点击单行「导入为自营」单独配置。 @@ -843,8 +948,64 @@ export default function PlatformProducts() { + {selectedLewanMap.size > 0 && ( +
+ + + + 已跨页勾选 {selectedLewanMap.size} 个商品 + + + + + + +
+ )} + { + setSelectedLewanMap((prev) => { + const next = new Map(prev) + const currentPageIds = new Set(lewanProducts.map((p) => p.id)) + for (const id of currentPageIds) { + if (!keys.includes(id)) { + next.delete(id) + } + } + for (const row of selectedRows) { + if (row && row.id) { + next.set(row.id, row) + } + } + return next + }) + }, + }} loading={lewanLoading} columns={lewanColumns} dataSource={lewanProducts} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9e8fa98..434d350 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -185,6 +185,8 @@ export interface DeliveryOrderInfo { product?: DeliveryProduct buyer_name: string amount: number + provider_order_no?: string + provider_name?: string created_at: string shipped_at?: string | null ship_fail_reason?: string diff --git a/frontend/src/utils/productImage.ts b/frontend/src/utils/productImage.ts new file mode 100644 index 0000000..1efa761 --- /dev/null +++ b/frontend/src/utils/productImage.ts @@ -0,0 +1,24 @@ +/** + * 解析商品图片地址: + * 1. 本地相对路径(以 / 开头)直接返回; + * 2. 乐玩/官方商品 UUID 地址,优先使用已下载至本地的图片缓存; + * 3. 其他外链图片走后端持久化缓存代理 (/api/delivery/v1/image-proxy),杜绝跨域拦截与画布污染。 + */ +export function resolveProductImage(url?: string | null): string { + if (!url) return '' + const trimmed = url.trim() + if (!trimmed) return '' + + if (trimmed.startsWith('/')) { + return trimmed + } + + // 匹配乐玩官方商品 ID:/products//... + const uuidMatch = trimmed.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/) + if (uuidMatch && uuidMatch[1]) { + return `/images/products/${uuidMatch[1]}.webp` + } + + // 其它外部地址统一经由代理及本地磁盘缓存返回 + return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(trimmed)}` +} diff --git a/frontend/src/utils/voucher.ts b/frontend/src/utils/voucher.ts new file mode 100644 index 0000000..93c5943 --- /dev/null +++ b/frontend/src/utils/voucher.ts @@ -0,0 +1,277 @@ +import type { DeliveryOrderInfo } from '../types' +import { formatDateTime } from './time' +import { resolveProductImage } from './productImage' + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('Failed to load image')) + img.src = src + }) +} + +/** + * 绘制高保真发货凭证卡片图片,返回 base64 PNG dataURL + */ +export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise { + const width = 1080 + const height = 1160 + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('Canvas 2D context not available') + } + + // 1. 背景底色 + ctx.fillStyle = '#f8fafc' + ctx.fillRect(0, 0, width, height) + + // 2. 主卡片容器 + const cardX = 40 + const cardY = 40 + const cardW = width - 80 + const cardH = height - 80 + const cardRadius = 24 + + // 卡片阴影与白色背景 + ctx.save() + ctx.fillStyle = '#ffffff' + ctx.shadowColor = 'rgba(15, 23, 42, 0.08)' + ctx.shadowBlur = 32 + ctx.shadowOffsetY = 16 + fillRoundRect(ctx, cardX, cardY, cardW, cardH, cardRadius) + ctx.restore() + + // 3. 顶部 Header (深色渐变) + const headerH = 220 + const grad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + headerH) + grad.addColorStop(0, '#0f172a') + grad.addColorStop(1, '#1e293b') + + ctx.save() + clipRoundRect(ctx, cardX, cardY, cardW, headerH, [cardRadius, cardRadius, 0, 0]) + ctx.fillStyle = grad + ctx.fillRect(cardX, cardY, cardW, headerH) + + // 顶部装饰圆点 + ctx.fillStyle = 'rgba(255, 255, 255, 0.05)' + ctx.beginPath() + ctx.arc(cardX + cardW - 60, cardY + 40, 120, 0, Math.PI * 2) + ctx.fill() + + // 平台徽标 / 标题 + ctx.fillStyle = '#38bdf8' + ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText('GAME FULFILLMENT · OFFICIAL RECEIPT', cardX + 48, cardY + 60) + + ctx.fillStyle = '#ffffff' + ctx.font = 'bold 44px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText('游戏道具发货电子凭证', cardX + 48, cardY + 124) + + ctx.fillStyle = '#94a3b8' + ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText('官方直充已完成 · 售后维权与电子核销依据', cardX + 48, cardY + 168) + ctx.restore() + + // 4. 发货成功电子印章 (Stamp) + drawSuccessStamp(ctx, cardX + cardW - 170, cardY + 140) + + // 5. 商品展示卡片 + const prodCardY = cardY + headerH + 32 + const prodCardH = 210 + ctx.fillStyle = '#f8fafc' + fillRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16) + ctx.strokeStyle = '#e2e8f0' + ctx.lineWidth = 1 + strokeRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16) + + // 尝试加载商品图片(优先命中本地下载缓存) + let productImgLoaded = false + if (order.product?.image) { + try { + const imgURL = resolveProductImage(order.product.image) + const img = await loadImage(imgURL) + const imgSize = 146 + const imgX = cardX + 64 + const imgY = prodCardY + 32 + ctx.save() + clipRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12) + ctx.drawImage(img, imgX, imgY, imgSize, imgSize) + ctx.restore() + ctx.strokeStyle = '#cbd5e1' + strokeRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12) + productImgLoaded = true + } catch { + productImgLoaded = false + } + } + + // 商品文字信息 + const textLeft = productImgLoaded ? cardX + 240 : cardX + 72 + ctx.fillStyle = '#0f172a' + ctx.font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(truncateString(order.product?.name || '游戏道具商品', 20), textLeft, prodCardY + 70) + + // 商品标签 (SKU & 游戏) + ctx.fillStyle = '#64748b' + ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + const skuText = `SKU: ${order.product?.sku || '-'}` + const gameText = order.product?.game ? ` · ${order.product.game}` : '' + ctx.fillText(skuText + gameText, textLeft, prodCardY + 120) + + // 金额与数量 + ctx.fillStyle = '#16a34a' + ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(`已发放: 1 件 | 价值: ${order.amount} 积分`, textLeft, prodCardY + 168) + + // 6. 核心凭证字段明细表 + const tableY = prodCardY + prodCardH + 32 + const tableW = cardW - 80 + const fields = [ + { label: '游戏账号 UID', value: order.game_uid || '-', highlight: true }, + { label: '游戏角色名', value: order.role_name || '-', highlight: true }, + { label: '区服信息', value: order.game_channel || '-' }, + { label: '下单时间', value: formatDateTime(order.created_at) }, + { label: '交付时间', value: order.shipped_at ? formatDateTime(order.shipped_at) : '已交付' }, + ] + + let curY = tableY + const rowHeight = 72 + fields.forEach((f, idx) => { + ctx.fillStyle = idx % 2 === 0 ? '#f8fafc' : '#ffffff' + fillRoundRect(ctx, cardX + 40, curY, tableW, rowHeight, 8) + + // 标签文字 + ctx.fillStyle = '#64748b' + ctx.font = '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText(f.label, cardX + 68, curY + 46) + + // 数值文字 + ctx.fillStyle = f.highlight ? '#0f172a' : '#334155' + ctx.font = f.highlight + ? 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + : '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + const valText = truncateString(f.value, 36) + ctx.fillText(valText, cardX + 320, curY + 46) + + curY += rowHeight + 8 + }) + + // 7. 底部防伪与存证声明 + const footerY = curY + 24 + ctx.strokeStyle = '#e2e8f0' + ctx.beginPath() + ctx.moveTo(cardX + 40, footerY) + ctx.lineTo(cardX + cardW - 40, footerY) + ctx.stroke() + + ctx.fillStyle = '#94a3b8' + ctx.font = '22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.fillText('注:此电子凭证为系统自动发货存证,道具已成功充值至指定角色,请登录游戏查收。', cardX + 48, footerY + 40) + ctx.fillText(`凭据生成时间:${formatDateTime(new Date().toISOString())}`, cardX + 48, footerY + 76) + + return canvas.toDataURL('image/png') +} + +// 辅助:绘制发货成功的印章 +function drawSuccessStamp(ctx: CanvasRenderingContext2D, x: number, y: number) { + ctx.save() + ctx.translate(x, y) + ctx.rotate((-14 * Math.PI) / 180) // 稍倾斜印章质感 + + // 外圈圆形边框 + ctx.strokeStyle = '#22c55e' + ctx.lineWidth = 4 + ctx.beginPath() + ctx.arc(0, 0, 68, 0, Math.PI * 2) + ctx.stroke() + + // 内圈虚线 + ctx.setLineDash([4, 4]) + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.arc(0, 0, 60, 0, Math.PI * 2) + ctx.stroke() + + // 印章文字 + ctx.fillStyle = '#22c55e' + ctx.font = 'bold 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('已发货完成', 0, -12) + + ctx.font = 'bold 15px sans-serif' + ctx.fillText('VERIFIED', 0, 16) + ctx.restore() +} + +function fillRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number | number[] +) { + ctx.beginPath() + roundRectPath(ctx, x, y, width, height, radius) + ctx.fill() +} + +function strokeRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number | number[] +) { + ctx.beginPath() + roundRectPath(ctx, x, y, width, height, radius) + ctx.stroke() +} + +function clipRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number | number[] +) { + ctx.beginPath() + roundRectPath(ctx, x, y, width, height, radius) + ctx.clip() +} + +function roundRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number | number[] +) { + const r = typeof radius === 'number' ? [radius, radius, radius, radius] : radius + const [tl, tr, br, bl] = r + ctx.moveTo(x + tl, y) + ctx.lineTo(x + width - tr, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + tr) + ctx.lineTo(x + width, y + height - br) + ctx.quadraticCurveTo(x + width, y + height, x + width - br, y + height) + ctx.lineTo(x + bl, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - bl) + ctx.lineTo(x, y + tl) + ctx.quadraticCurveTo(x, y, x + tl, y) + ctx.closePath() +} + +function truncateString(str: string, maxLen: number): string { + if (!str) return '-' + if (str.length <= maxLen) return str + return str.slice(0, maxLen - 1) + '…' +}