feat: 优化发货交付页面与凭据生成,支持一键复制结果图片及官方商品图片本地缓存
- 交付界面重构:隐藏内部订单号、流水号与发货渠道,展示聚焦于买家游戏UID、角色名与交付时间 - 发货结果凭据生成:采用 Canvas 生成高清发货存证图,优化存证说明文案与自适应排版 - 一键复制图片:支持直接将结果凭证图片写入系统剪贴板,方便客服在聊天窗口快捷粘贴 - 官方图片本地化缓存:全量下载乐玩商品官方主图并建立本地持久化路由,规避外部源站跨域及破损问题 - 修复乐玩渠道角色UID提取与履约状态幂等回填逻辑
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user