586 lines
16 KiB
C#
586 lines
16 KiB
C#
using MAX.Core.Extensions;
|
||
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
using Org.BouncyCastle.Asn1.Cms;
|
||
using Store.Framework.Models;
|
||
using Store.Modules.CloudProduct.Entities;
|
||
using Store.Modules.CloudProduct.ImportConverters;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Net.ServerSentEvents;
|
||
using System.Text;
|
||
using System.Text.Json.Nodes;
|
||
|
||
namespace Store.CloudProduct.ImportConverters
|
||
{
|
||
internal class WXGoodsConverter : GoodsConverter
|
||
{
|
||
public override string ConverterName => nameof(WXGoodsConverter);
|
||
|
||
public override PlatformType Platform => PlatformType.WX;
|
||
private JObject _jsonRoot;
|
||
private JObject _productNode; // detail.product
|
||
private JObject _infoNode; // detail.product.info
|
||
private JArray _skuList; // detail.product.info.sku_list.sku_info(价格兜底)
|
||
private JArray _productSkus; // detail.product.product_skus(完整SKU)
|
||
private JArray _saleAttrs; // detail.product.info.product_sale_attrs
|
||
|
||
protected override bool IsMatchCore()
|
||
{
|
||
try
|
||
{
|
||
_jsonRoot = JsonConvert.DeserializeObject<JObject>(RawData);
|
||
if (_jsonRoot == null) return false;
|
||
|
||
var detail = _jsonRoot.SelectToken("$.detail") as JObject;
|
||
if (detail == null) return false;
|
||
|
||
var product = detail.SelectToken("$.product") as JObject;
|
||
if (product == null) return false;
|
||
|
||
var productKey = product.SelectToken("$.product_key") as JObject;
|
||
if (productKey == null) return false;
|
||
|
||
var productId = productKey.SelectToken("$.product_id")?.ToString();
|
||
if (string.IsNullOrEmpty(productId)) return false;
|
||
|
||
var info = product.SelectToken("$.info") as JObject;
|
||
if (info == null) return false;
|
||
|
||
var title = info.SelectToken("$.title")?.ToString();
|
||
if (string.IsNullOrEmpty(title)) return false;
|
||
|
||
var productSkus = product.SelectToken("$.product_skus") as JArray;
|
||
var skuList = info.SelectToken("$.sku_list.sku_info") as JArray;
|
||
|
||
if ((productSkus == null || productSkus.Count == 0) &&
|
||
(skuList == null || skuList.Count == 0))
|
||
return false;
|
||
|
||
_productNode = product;
|
||
_infoNode = info;
|
||
_productSkus = productSkus;
|
||
_skuList = skuList;
|
||
_saleAttrs = info.SelectToken("$.product_sale_attrs") as JArray;
|
||
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
protected override GoodsEntity ConvertCore()
|
||
{
|
||
var product = ConverterProvider();
|
||
var item = new
|
||
GoodsEntity
|
||
{
|
||
Platform = (byte)PlatformType.WX,
|
||
GoodsId = product.ProductId.ToLong(0),
|
||
Title = product.Title ?? string.Empty,
|
||
Description = product.Description,
|
||
ThumbUrl = product.ThumbUrl,
|
||
SoldQuantity = GetTotalSold(),
|
||
Status = 0,
|
||
};
|
||
|
||
item.MinPrice = ((long)product.Skus.Min(a=>a.Price));
|
||
item.MaxPrice = ((long)product.Skus.Max(a => a.Price));
|
||
item.CatId1 = product.CategoryIds.Count > 0 ? product.CategoryIds[0] : (int?)null;
|
||
item.CatId2 = product.CategoryIds.Count > 1 ? product.CategoryIds[1] : (int?)null;
|
||
item.CatId3 = product.CategoryIds.Count > 2 ? product.CategoryIds[2] : (int?)null;
|
||
item.CatId4 = product.CategoryIds.Count > 3 ? product.CategoryIds[3] : (int?)null;
|
||
|
||
return item;
|
||
}
|
||
private Product ConverterProvider()
|
||
{
|
||
var product = new Product
|
||
{
|
||
Platform = Platform,
|
||
ProductId = ParseGoodsId()
|
||
};
|
||
|
||
// 1. 基本信息
|
||
product.Title = GetStringValue(_infoNode, "title");
|
||
product.Description = GetStringValue(_infoNode, "short_title");
|
||
if (string.IsNullOrEmpty(product.Description))
|
||
product.Description = GetStringValue(_infoNode, "ai_gen_short_title");
|
||
|
||
// 2. 品牌(新模型无 BrandId,只有 Brand 名称)
|
||
var brandId = GetLongValue(_infoNode, "brand_id");
|
||
product.Brand = brandId > 0 ? brandId.ToString() : string.Empty;
|
||
|
||
// 3. 类目
|
||
product.CategoryId = GetCategoryId();
|
||
product.CategoryIds = GetCategoryPath();
|
||
|
||
// 4. 主图
|
||
product.MainImages = GetMainImages();
|
||
|
||
// 5. 详情图
|
||
product.DetailImages = GetDetailImages();
|
||
|
||
// 6. 属性
|
||
product.Attributes = GetProperties();
|
||
|
||
// 7. 规格 + SKU
|
||
var skuResult = GetSkusAndSpecs();
|
||
product.Skus = skuResult.Item1;
|
||
product.Specs = skuResult.Item2;
|
||
|
||
// 8. 媒体与视频(新模型只有单个 Video)
|
||
product.ThumbUrl = product.MainImages.FirstOrDefault() ?? string.Empty;
|
||
product.Video = GetVideoUrls().FirstOrDefault() ?? string.Empty;
|
||
|
||
return product;
|
||
}
|
||
|
||
#region 核心字段解析
|
||
|
||
private string ParseGoodsId()
|
||
{
|
||
var productId = _productNode.SelectToken("$.product_key.product_id")?.ToString();
|
||
if (!string.IsNullOrEmpty(productId))
|
||
return productId;
|
||
return "";
|
||
}
|
||
|
||
private int GetCategoryId()
|
||
{
|
||
var category = _infoNode["category"] as JArray;
|
||
if (category == null || category.Count == 0)
|
||
return 0;
|
||
|
||
var lastCat = category.Last as JObject;
|
||
return lastCat?["cat_id"]?.Value<int>() ?? 0;
|
||
}
|
||
|
||
private List<int> GetCategoryPath()
|
||
{
|
||
var result = new List<int>();
|
||
var category = _infoNode["category"] as JArray;
|
||
if (category == null) return result;
|
||
|
||
foreach (var cat in category)
|
||
{
|
||
var catId = cat["cat_id"]?.Value<int>();
|
||
if (catId.HasValue && catId.Value > 0)
|
||
result.Add(catId.Value);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private List<string> GetMainImages()
|
||
{
|
||
var result = new List<string>();
|
||
|
||
var headImg = _infoNode["head_img"] as JArray;
|
||
if (headImg != null && headImg.Count > 0)
|
||
{
|
||
foreach (var img in headImg)
|
||
{
|
||
var url = img?.Value<string>();
|
||
if (!string.IsNullOrEmpty(url))
|
||
result.Add(url);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
var headImgInfos = _infoNode["head_img_infos"] as JArray;
|
||
if (headImgInfos != null)
|
||
{
|
||
foreach (var img in headImgInfos)
|
||
{
|
||
var url = img["img_url"]?.Value<string>();
|
||
if (!string.IsNullOrEmpty(url))
|
||
result.Add(url);
|
||
}
|
||
}
|
||
|
||
return result.Distinct().ToList();
|
||
}
|
||
|
||
private List<string> GetDetailImages()
|
||
{
|
||
var result = new List<string>();
|
||
var detail = _infoNode["detail"] as JObject;
|
||
if (detail == null) return result;
|
||
|
||
var detailImg = detail["detail_img"] as JArray;
|
||
if (detailImg == null) return result;
|
||
|
||
foreach (var img in detailImg)
|
||
{
|
||
var url = img?.Value<string>();
|
||
if (!string.IsNullOrEmpty(url))
|
||
result.Add(url);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 属性转换为新模型结构:多个同名key的value合并到 Values 列表
|
||
/// </summary>
|
||
private List<ProductAttribute> GetProperties()
|
||
{
|
||
var result = new List<ProductAttribute>();
|
||
var param = _infoNode["param"] as JArray;
|
||
if (param == null) return result;
|
||
|
||
foreach (var item in param)
|
||
{
|
||
var categorys = item["categorys"] as JArray;
|
||
if (categorys == null || categorys.Count < 2) continue;
|
||
|
||
var key = categorys[0]?["name"]?.Value<string>();
|
||
var value = categorys[1]?["name"]?.Value<string>();
|
||
|
||
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) continue;
|
||
|
||
var existing = result.FirstOrDefault(p => p.Name == key);
|
||
if (existing == null)
|
||
{
|
||
result.Add(new ProductAttribute
|
||
{
|
||
Name = key,
|
||
Values = new List<string> { value }
|
||
});
|
||
}
|
||
else if (!existing.Values.Contains(value))
|
||
{
|
||
existing.Values.Add(value);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private List<string> GetVideoUrls()
|
||
{
|
||
var result = new List<string>();
|
||
var headVideoInfo = _infoNode["head_video_info"] as JArray;
|
||
if (headVideoInfo == null) return result;
|
||
|
||
foreach (var video in headVideoInfo)
|
||
{
|
||
var url = video["video_url"]?.Value<string>();
|
||
if (!string.IsNullOrEmpty(url))
|
||
result.Add(url);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region SKU和规格解析
|
||
|
||
private Tuple<List<ProductSku>, List<ProductSpec>> GetSkusAndSpecs()
|
||
{
|
||
var skuList = new List<ProductSku>();
|
||
var specList = new List<ProductSpec>();
|
||
|
||
var priceFallbackDict = BuildPriceFallbackDict();
|
||
var attrFallbackDict = BuildAttrFallbackDict();
|
||
|
||
if (_productSkus != null && _productSkus.Count > 0)
|
||
{
|
||
foreach (var sku in _productSkus)
|
||
{
|
||
var skuObj = sku as JObject;
|
||
if (skuObj == null) continue;
|
||
|
||
var skuItem = ParseProductSku(skuObj, priceFallbackDict, attrFallbackDict);
|
||
if (skuItem != null)
|
||
skuList.Add(skuItem);
|
||
}
|
||
}
|
||
else if (_skuList != null && _skuList.Count > 0)
|
||
{
|
||
foreach (var sku in _skuList)
|
||
{
|
||
var skuObj = sku as JObject;
|
||
if (skuObj == null) continue;
|
||
|
||
var skuItem = ParseSkuInfoOnly(skuObj, attrFallbackDict);
|
||
if (skuItem != null)
|
||
skuList.Add(skuItem);
|
||
}
|
||
}
|
||
|
||
BuildSpecList(specList, skuList);
|
||
|
||
return new Tuple<List<ProductSku>, List<ProductSpec>>(skuList, specList);
|
||
}
|
||
|
||
private Dictionary<string, decimal> BuildPriceFallbackDict()
|
||
{
|
||
var dict = new Dictionary<string, decimal>();
|
||
if (_skuList == null) return dict;
|
||
|
||
foreach (var sku in _skuList)
|
||
{
|
||
var skuObj = sku as JObject;
|
||
if (skuObj == null) continue;
|
||
|
||
var skuId = skuObj["sku_id"]?.Value<string>();
|
||
if (string.IsNullOrEmpty(skuId)) continue;
|
||
|
||
var estimatePrice = skuObj["estimate_price"]?.Value<decimal>() ?? 0;
|
||
var price = skuObj["price"]?.Value<decimal>() ?? 0;
|
||
dict[skuId] = estimatePrice > 0 ? estimatePrice : price;
|
||
}
|
||
|
||
return dict;
|
||
}
|
||
|
||
/// <summary>
|
||
/// skuId -> 结构化的规格列表
|
||
/// </summary>
|
||
private Dictionary<string, List<ProductSkuSpec>> BuildAttrFallbackDict()
|
||
{
|
||
var dict = new Dictionary<string, List<ProductSkuSpec>>();
|
||
if (_saleAttrs == null) return dict;
|
||
|
||
foreach (var attr in _saleAttrs)
|
||
{
|
||
var key = attr["key"]?.Value<string>() ?? string.Empty;
|
||
var items = attr["items"] as JArray;
|
||
if (items == null) continue;
|
||
|
||
foreach (var item in items)
|
||
{
|
||
var value = item["value_v2"]?.Value<string>();
|
||
if (string.IsNullOrEmpty(value))
|
||
value = item["value"]?.Value<string>();
|
||
if (string.IsNullOrEmpty(value)) continue;
|
||
|
||
var skuIds = item["sku_ids"] as JArray;
|
||
if (skuIds == null) continue;
|
||
|
||
foreach (var skuIdToken in skuIds)
|
||
{
|
||
var id = skuIdToken?.Value<string>();
|
||
if (string.IsNullOrEmpty(id)) continue;
|
||
|
||
if (!dict.ContainsKey(id))
|
||
dict[id] = new List<ProductSkuSpec>();
|
||
|
||
dict[id].Add(new ProductSkuSpec { Name = key, Value = value });
|
||
}
|
||
}
|
||
}
|
||
|
||
return dict;
|
||
}
|
||
|
||
private ProductSku ParseProductSku(
|
||
JObject skuObj,
|
||
Dictionary<string, decimal> priceFallbackDict,
|
||
Dictionary<string, List<ProductSkuSpec>> attrFallbackDict)
|
||
{
|
||
var skuKey = skuObj["sku_key"] as JObject;
|
||
var skuInfo = skuObj["product_sku_info"] as JObject;
|
||
if (skuKey == null || skuInfo == null) return null;
|
||
|
||
var skuId = skuKey["sku_id"]?.Value<string>() ?? string.Empty;
|
||
var outSkuId = skuKey["out_sku_id"]?.Value<string>() ?? string.Empty;
|
||
if (string.IsNullOrEmpty(skuId)) return null;
|
||
|
||
var skuSpecs = ParseSkuSpecs(skuInfo, skuId, attrFallbackDict);
|
||
|
||
var salePrice = skuInfo["sale_price"]?.Value<decimal>() ?? 0;
|
||
if (salePrice <= 0 && priceFallbackDict.TryGetValue(skuId, out var fallbackPrice))
|
||
salePrice = fallbackPrice;
|
||
|
||
var stockNum = ParseStockNum(skuInfo);
|
||
var thumbImg = skuInfo["thumb_img"]?.Value<string>() ?? string.Empty;
|
||
|
||
return new ProductSku
|
||
{
|
||
SkuId = !string.IsNullOrEmpty(outSkuId) ? outSkuId : skuId,
|
||
Price = salePrice / 100,
|
||
Stock = stockNum,
|
||
ImageUrl = thumbImg,
|
||
Specs = skuSpecs
|
||
};
|
||
}
|
||
|
||
private ProductSku ParseSkuInfoOnly(
|
||
JObject skuObj,
|
||
Dictionary<string, List<ProductSkuSpec>> attrFallbackDict)
|
||
{
|
||
var skuId = skuObj["sku_id"]?.Value<string>() ?? string.Empty;
|
||
if (string.IsNullOrEmpty(skuId)) return null;
|
||
|
||
var skuSpecs = attrFallbackDict.TryGetValue(skuId, out var specs)
|
||
? specs
|
||
: new List<ProductSkuSpec>();
|
||
|
||
var estimatePrice = skuObj["estimate_price"]?.Value<decimal>() ?? 0;
|
||
var price = skuObj["price"]?.Value<decimal>() ?? 0;
|
||
var actualPrice = estimatePrice > 0 ? estimatePrice : price;
|
||
|
||
return new ProductSku
|
||
{
|
||
SkuId = skuId,
|
||
Price = actualPrice / 100,
|
||
Stock = 0,
|
||
ImageUrl = string.Empty,
|
||
Specs = skuSpecs
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析SKU的结构化规格列表
|
||
/// </summary>
|
||
private List<ProductSkuSpec> ParseSkuSpecs(
|
||
JObject skuInfo,
|
||
string skuId,
|
||
Dictionary<string, List<ProductSkuSpec>> attrFallbackDict)
|
||
{
|
||
var specs = new List<ProductSkuSpec>();
|
||
var saleParams = skuInfo["sale_param"] as JArray;
|
||
|
||
if (saleParams != null)
|
||
{
|
||
foreach (var param in saleParams)
|
||
{
|
||
var paramV2 = param["param_v2"] as JObject;
|
||
if (paramV2 != null)
|
||
{
|
||
var attrName = paramV2["attr_name"]?.Value<string>() ?? string.Empty;
|
||
var attrValue = paramV2["attr_value_name"]?.Value<string>() ?? string.Empty;
|
||
|
||
if (!string.IsNullOrEmpty(attrName) && !string.IsNullOrEmpty(attrValue))
|
||
{
|
||
specs.Add(new ProductSkuSpec { Name = attrName, Value = attrValue });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
var categorys = param["categorys"] as JArray;
|
||
if (categorys != null && categorys.Count >= 2)
|
||
{
|
||
var attrName = categorys[0]?["name"]?.Value<string>() ?? string.Empty;
|
||
var attrValue = categorys[1]?["name"]?.Value<string>() ?? string.Empty;
|
||
|
||
if (!string.IsNullOrEmpty(attrName) && !string.IsNullOrEmpty(attrValue))
|
||
{
|
||
specs.Add(new ProductSkuSpec { Name = attrName, Value = attrValue });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (specs.Count == 0 && attrFallbackDict.TryGetValue(skuId, out var fallbackSpecs))
|
||
{
|
||
specs.AddRange(fallbackSpecs);
|
||
}
|
||
|
||
return specs;
|
||
}
|
||
|
||
private int ParseStockNum(JObject skuInfo)
|
||
{
|
||
var stockInfo = skuInfo["stock_info"] as JObject;
|
||
if (stockInfo == null) return 0;
|
||
|
||
var stockToken = stockInfo["stock_num"];
|
||
if (stockToken == null) return 0;
|
||
|
||
if (stockToken.Type == JTokenType.String)
|
||
return int.TryParse(stockToken.Value<string>(), out var result) ? result : 0;
|
||
|
||
return stockToken.Value<int>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建聚合后的规格定义列表
|
||
/// </summary>
|
||
private void BuildSpecList(List<ProductSpec> specList, List<ProductSku> skus)
|
||
{
|
||
var specDict = new Dictionary<string, HashSet<string>>();
|
||
|
||
// 优先从 product_sale_attrs
|
||
if (_saleAttrs != null && _saleAttrs.Count > 0)
|
||
{
|
||
foreach (var attr in _saleAttrs)
|
||
{
|
||
var key = attr["key"]?.Value<string>() ?? string.Empty;
|
||
var items = attr["items"] as JArray;
|
||
if (items == null) continue;
|
||
|
||
foreach (var item in items)
|
||
{
|
||
var value = item["value_v2"]?.Value<string>();
|
||
if (string.IsNullOrEmpty(value))
|
||
value = item["value"]?.Value<string>();
|
||
if (string.IsNullOrEmpty(value)) continue;
|
||
|
||
if (!specDict.ContainsKey(key))
|
||
specDict[key] = new HashSet<string>();
|
||
specDict[key].Add(value);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回退:从SKU的结构化规格解析
|
||
if (specDict.Count == 0 && skus.Count > 0)
|
||
{
|
||
foreach (var sku in skus)
|
||
{
|
||
foreach (var s in sku.Specs)
|
||
{
|
||
if (string.IsNullOrEmpty(s.Name)) continue;
|
||
if (!specDict.ContainsKey(s.Name))
|
||
specDict[s.Name] = new HashSet<string>();
|
||
specDict[s.Name].Add(s.Value);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var spec in specDict)
|
||
{
|
||
specList.Add(new ProductSpec
|
||
{
|
||
Name = spec.Key,
|
||
Values = spec.Value.ToList()
|
||
});
|
||
}
|
||
}
|
||
private int GetTotalSold()
|
||
{
|
||
var stockInfo = _infoNode["total_sold_num"] as JToken;
|
||
if (stockInfo == null) return 0;
|
||
return stockInfo.Value<int>();
|
||
}
|
||
#endregion
|
||
|
||
#region 辅助方法
|
||
|
||
private string GetStringValue(JObject obj, string path)
|
||
=> obj?.SelectToken(path)?.Value<string>() ?? string.Empty;
|
||
|
||
private long GetLongValue(JObject obj, string path)
|
||
{
|
||
var token = obj?.SelectToken(path);
|
||
if (token == null) return 0;
|
||
|
||
if (token.Type == JTokenType.String)
|
||
return long.TryParse(token.Value<string>(), out var result) ? result : 0;
|
||
|
||
return token.Value<long>();
|
||
}
|
||
|
||
#endregion
|
||
|
||
|
||
|
||
}
|
||
}
|