Files
KJD/Work/Services/CloudGoodsService.cs
T
2026-11-05 18:50:13 +08:00

746 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using AllBase;
using AllBase.Helper;
using AllBase.Model.Result;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using Work.Store.Api;
using System.Threading.Tasks;
using Work.Api.Wx;
namespace Work.Services
{
/// <summary>
/// 云商品数据服务
/// </summary>
public class CloudGoodsService
{
public static async Task<PageData<CloudGoods>> GetGoods(GoodsSearchRequest request)
{
return await StoreApiClient.Instance.CloudGoods.GetGoods(request);
}
public static string GetGoodsDetail(long goodsId)
{
return StoreApiClient.Instance.CloudGoods.GetGoodsDetail(goodsId);
}
/// <summary>
/// 在后台解析微信处罚 JSON、匹配店铺商品编码并上报,异常在内部记录。
/// </summary>
public static void ReportViolationsFromJson(string json, ShopLoginInfo shop)
{
_ = Task.Run(async () =>
{
try
{
await ReportViolationsFromJsonCore(json, shop).ConfigureAwait(false);
}
catch (Exception ex)
{
try
{
LogSave.AddFile("解析并上报违规商品失败:" + ex.Message);
}
catch
{
// 日志失败也不能影响调用方。
}
}
});
}
private static async Task<List<ReportGoodsViolationResult>> ReportViolationsFromJsonCore(string json, ShopLoginInfo shop)
{
if (string.IsNullOrWhiteSpace(json))
{
throw new ArgumentException("处罚 JSON 文本不能为空。", nameof(json));
}
JObject root;
try
{
root = JObject.Parse(json);
}
catch (JsonException ex)
{
throw new ArgumentException("处罚 JSON 格式不正确。", nameof(json), ex);
}
var record = root["record"] as JObject;
if (root["code"]?.ToString() != "0" || record?["code"]?.ToString() != "0")
{
throw new ArgumentException("处罚数据的 code 或 record.code 不为 0,或缺少 record。", nameof(json));
}
if (!(record["items"] is JArray records))
{
throw new ArgumentException("处罚数据缺少 record.items 数组。", nameof(json));
}
if (records.Count == 0)
return new List<ReportGoodsViolationResult>();
var items = new List<ReportGoodsViolationItem>();
foreach (var token in records)
{
if (!(token is JObject punishment))
{
throw new ArgumentException("record.items 中的处罚记录必须是对象。", nameof(json));
}
var objects = punishment["punishObject"];
if (objects == null || objects.Type == JTokenType.Null)
{
continue;
}
if (!(objects is JArray products))
{
throw new ArgumentException("punishObject 必须是数组。", nameof(json));
}
foreach (var product in products)
{
if (!(product is JObject target))
{
throw new ArgumentException("punishObject 中的处罚对象必须是对象。", nameof(json));
}
var id = target["productId"];
// 订单等非商品处罚对象不包含 productId。
if (id == null)
{
continue;
}
if ((id.Type != JTokenType.String && id.Type != JTokenType.Integer)
|| !long.TryParse(id.ToString(), out var goodsId) || goodsId <= 0)
{
throw new ArgumentException("商品 productId 必须是有效的正整数编号。", nameof(json));
}
var reason = punishment["punishReason"];
if (reason?.Type != JTokenType.String || string.IsNullOrWhiteSpace((string)reason))
{
throw new ArgumentException($"商品 {goodsId} 缺少有效的 punishReason。", nameof(json));
}
items.Add(new ReportGoodsViolationItem { GoodsId = goodsId, Reasons = ((string)reason).Trim() });
}
}
if (items.Count == 0)
{
return new List<ReportGoodsViolationResult>();
}
var productCodes = GetWxProductCodes(shop, items.Select(item => item.GoodsId));
var mappedItems = new List<ReportGoodsViolationItem>();
foreach (var item in items)
{
if (!productCodes.TryGetValue(item.GoodsId, out var code) || string.IsNullOrWhiteSpace(code))
{
LogSave.AddFile($"跳过违规商品 {item.GoodsId}:未找到对应的 spuCode。");
continue;
}
if (!TryGetGoodsId(code, out var goodsId))
{
LogSave.AddFile($"跳过违规商品 {item.GoodsId}:spuCode 无法转换为有效商品编号。");
continue;
}
mappedItems.Add(new ReportGoodsViolationItem { GoodsId = goodsId, Reasons = item.Reasons });
}
var reports = mappedItems.GroupBy(item => new { item.GoodsId, item.Reasons }).Select(group => group.First()).ToList();
var results = new List<ReportGoodsViolationResult>();
for (int offset = 0; offset < reports.Count && GlobalConfig.GlobalSettings.AutoReportViolationProducts; offset += 1000)
{
var batch = reports.Skip(offset).Take(1000).ToList();
results.AddRange(await ReportViolations(batch));
await RecordReportLogs(shop.ShopId, batch);
}
return results;
}
private static async Task RecordReportLogs(string shopId, List<ReportGoodsViolationItem> batch)
{
if (!GlobalConfig.GlobalSettings.AutoReportViolationProducts) return;
Dictionary<string, int> reasonIds;
try
{
// 上报成功后读取字典,才能取得本批新增违规原因的编号。
reasonIds = (await GetViolationReasons()).ToDictionary(reason => reason.Name, reason => reason.Id);
}
catch (Exception ex)
{
LogSave.AddFile($"店铺 {shopId} 已上报违规商品,读取日志原因字典失败:{ex.Message}");
return;
}
var logs = new List<RecordViolationLogRequest>(batch.Count);
foreach (var item in batch)
{
if (!reasonIds.TryGetValue(item.Reasons, out var reasonId))
{
LogSave.AddFile($"店铺 {shopId} 商品 {item.GoodsId} 找不到违规原因编号:{item.Reasons}");
continue;
}
logs.Add(new RecordViolationLogRequest
{
ShopId = shopId, GoodsId = item.GoodsId, ReasonId = reasonId, Action = ViolationLogAction.Report
});
}
await TryRecordLogs(logs);
}
private static bool TryGetGoodsId(string code, out long goodsId)
{
code = code?.Trim() ?? string.Empty;
if (code.StartsWith("P1-", StringComparison.Ordinal))
{
code = code.Substring(3);
}
else if (code.StartsWith("EC1", StringComparison.Ordinal) && code.EndsWith("A", StringComparison.Ordinal))
{
code = code.Substring(3, code.Length - 4);
}
return long.TryParse(code, out goodsId) && goodsId > 0;
}
/// <summary>
/// 后台检查当前店铺所有在售商品,云端确认违规后立即下架;异常仅记录日志。
/// </summary>
public static void CheckAndOffShelfViolations(ShopLoginInfo shop)
{
_ = Task.Run(async () =>
{
try
{
await CheckAndOffShelfViolationsCore(shop).ConfigureAwait(false);
}
catch (Exception ex)
{
try
{
LogSave.AddFile($"店铺 {shop?.ShopId} 在售商品违规检查失败:{ex.Message}");
}
catch { }
}
});
}
private static async Task CheckAndOffShelfViolationsCore(ShopLoginInfo shop)
{
// 先读取完整列表,避免下架改变分页位置而漏查后面的商品。
var products = GetWxProductCodes(shop, status: 5);
var mappings = new Dictionary<long, long>();
foreach (var product in products)
{
if (TryGetGoodsId(product.Value, out var goodsId))
{
mappings.Add(product.Key, goodsId);
}
else
{
LogSave.AddFile($"店铺 {shop.ShopId} 跳过商品 {product.Key}:spuCode 无法转换为有效商品编号。");
}
}
var goodsIds = mappings.Values.Distinct().ToList();
for (var offset = 0; offset < goodsIds.Count; offset += 50)
{
var batch = goodsIds.Skip(offset).Take(50).ToList();
var statuses = GetViolationStatus(batch);
var violations = statuses.Where(item => item != null && item.IsViolation && batch.Contains(item.GoodsId))
.ToDictionary(item => item.GoodsId);
var productIds = mappings.Where(item => violations.ContainsKey(item.Value)).Select(item => item.Key).ToList();
// 同一云商品可能对应多个微信商品,每次下架最多 50 个。
for (var index = 0; index < productIds.Count; index += 50)
{
if (!GlobalConfig.GlobalSettings.AutoDelistViolationProducts)
return;
var page = productIds.Skip(index).Take(50).ToArray();
var ids = string.Join(",", page);
var response = JObject.Parse(WxProductApi.DownProd(shop, ids));
if (response["code"]?.ToString() != "0")
{
throw new Exception($"微信商品 {ids} 下架失败:{response}");
}
LogSave.AddFile($"店铺 {shop.ShopId} 云端检测违规,已下架微信商品:{ids}");
var downed = page.Select(id => mappings[id]).Distinct().Select(id => violations[id]);
await RecordShopActionLogs(shop.ShopId, downed, ViolationLogAction.AutoOffShelf);
}
}
}
private static Dictionary<long, string> GetWxProductCodes(ShopLoginInfo shop, IEnumerable<long> productIds = null, int status = -1)
{
if (shop == null)
{
throw new ArgumentNullException(nameof(shop));
}
var codes = new Dictionary<long, string>();
var prodIds = productIds == null ? null : string.Join(",", productIds.Distinct());
long received = 0;
for (var page = 1; ; page++)
{
var response = JObject.Parse(WxProductApi.SearchProduct(shop, page, pageSize: 50, prodIds: prodIds, status: status));
if (response["ret"]?.ToString() != "0" || response["success"]?.Type != JTokenType.Boolean
|| !(bool)response["success"] || !(response["productList"] is JArray products)
|| !long.TryParse(response["totalNum"]?.ToString(), out var total) || total < 0)
{
throw new Exception($"获取微信商品列表第 {page} 页失败。");
}
var previousCount = codes.Count;
foreach (var product in products)
{
if (!(product is JObject item) || !long.TryParse(item["productId"]?.ToString(), out var id) || id <= 0)
{
throw new Exception("微信商品列表包含无效的 productId。");
}
codes[id] = item["spuCode"]?.Type == JTokenType.String ? (string)item["spuCode"] : null;
}
received += products.Count;
if (received >= total)
{
return codes;
}
if (codes.Count == previousCount)
{
throw new Exception("微信商品列表分页未返回新商品,无法获取全部搜索结果。");
}
}
}
/// <summary>
/// 批量上报违规商品。
/// </summary>
public static async Task<List<ReportGoodsViolationResult>> ReportViolations(List<ReportGoodsViolationItem> items)
{
if (items == null)
{
throw new ArgumentNullException(nameof(items));
}
if (items.Any(item => item == null))
{
throw new ArgumentException("违规商品列表不能包含空项。", nameof(items));
}
return await StoreApiClient.Instance.CloudGoods.ReportViolations(items);
}
/// <summary>
/// 批量查询商品违规状态。
/// </summary>
public static List<GoodsViolationStatus> GetViolationStatus(List<long> goodsIds)
{
if (goodsIds == null)
{
throw new ArgumentNullException(nameof(goodsIds));
}
return StoreApiClient.Instance.CloudGoods.GetViolationStatus(goodsIds);
}
/// <summary>逐原因记录店铺动作。日志请求失败不能改变剔除决定或中断后续下架。</summary>
public static Task RecordShopActionLogs(string shopId, GoodsViolationStatus status, ViolationLogAction action)
=> RecordShopActionLogs(shopId, new[] { status }, action);
private static async Task RecordShopActionLogs(string shopId, IEnumerable<GoodsViolationStatus> statuses, ViolationLogAction action)
{
if (!(action == ViolationLogAction.AutoListingExcluded && GlobalConfig.GlobalSettings.AutoExcludeViolationProductsOnUpload
|| action == ViolationLogAction.AutoOffShelf && GlobalConfig.GlobalSettings.AutoDelistViolationProducts))
return;
var logs = new List<RecordViolationLogRequest>();
foreach (var status in statuses)
{
if (status.ReasonIds == null || status.ReasonIds.Length == 0)
{
LogSave.AddFile($"店铺 {shopId} 商品 {status.GoodsId} 缺少违规原因编号,无法上传动作 {action} 日志。");
continue;
}
logs.AddRange(status.ReasonIds.Select(id => new RecordViolationLogRequest
{
ShopId = shopId, GoodsId = status.GoodsId, ReasonId = id, Action = action
}));
}
await TryRecordLogs(logs).ConfigureAwait(false);
}
private static async Task TryRecordLogs(List<RecordViolationLogRequest> logs)
{
if (logs.Count == 0) return;
try
{
await RecordViolationLogs(logs).ConfigureAwait(false);
}
catch (Exception ex)
{
LogSave.AddFile($"店铺 {logs[0].ShopId} 动作 {logs[0].Action} 的 {logs.Count} 条日志上传失败:{ex.Message}");
}
}
/// <summary>记录店铺已完成的违规上报、上货剔除或下架动作。</summary>
public static Task RecordViolationLog(RecordViolationLogRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
return RecordViolationLogs(new[] { request });
}
public static async Task RecordViolationLogs(IReadOnlyList<RecordViolationLogRequest> requests)
{
if (requests == null || requests.Count == 0 || requests.Any(item => item == null))
throw new ArgumentException("请提供有效的违规日志。", nameof(requests));
await StoreApiClient.Instance.CloudGoods.RecordViolationLogs(requests);
}
/// <summary>读取日志筛选和显示使用的违规原因字典。</summary>
public static async Task<List<ViolationReason>> GetViolationReasons()
{
return await StoreApiClient.Instance.CloudGoods.GetViolationReasons();
}
public class ViolationReason
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsViolation { get; set; }
}
/// <summary>按日志编号倒序查询,下一页传入上一页最后一条日志的 Id。</summary>
public static async Task<List<GoodsViolationLogEntity>> GetViolationLogs(string[] shopIds, long? goodsId = null,
long beforeId = 0, int size = 50)
{
if (shopIds == null || shopIds.Length == 0 || shopIds.Any(string.IsNullOrWhiteSpace))
throw new ArgumentException("请提供有效的店铺编号。", nameof(shopIds));
var request = new GetViolationLogsRequest { ShopIds = shopIds, GoodsId = goodsId, BeforeId = beforeId, Size = size };
return await StoreApiClient.Instance.CloudGoods.GetViolationLogs(request);
}
#region user
/// <summary>
/// 搜索收藏
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public static async Task<PageData<CloudGoods>> SearchFavorites(UserFavoritesGoodsSearchRequest request)
{
request.UserName = GetUserName();
var page = await StoreApiClient.Instance.CloudGoods.SearchFavorites(request);
foreach (var item in page.List)
{
item.DetectionResult = item.IsSafe == null ? "未检测" : item.IsSafe.Value ? "安全" : "违规";
}
return page;
}
/// <summary>
/// 收藏
/// </summary>
/// <param name="goodsIds"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public static async Task<int> Favorite(List<long> goodsIds)
{
return await StoreApiClient.Instance.CloudGoods.Favorite(GetUserName(), goodsIds);
}
/// <summary>
/// 取消收藏
/// </summary>
/// <param name="goodsIds"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public static async Task<int> RemoveFavorites(List<long> goodsIds)
{
return await StoreApiClient.Instance.CloudGoods.RemoveFavorites(GetUserName(), goodsIds);
}
#endregion
public static string GetUserName()
{
return string.IsNullOrEmpty(BaseClass.ParentUserName) ? BaseClass.UserName : BaseClass.ParentUserName;
}
}
public enum ViolationLogAction
{
Report = 1,
AutoListingExcluded = 2,
AutoOffShelf = 3
}
public sealed class RecordViolationLogRequest
{
public string ShopId { get; set; } = string.Empty;
public long GoodsId { get; set; }
public int ReasonId { get; set; }
public ViolationLogAction Action { get; set; }
}
public sealed class GetViolationLogsRequest
{
public string[] ShopIds { get; set; } = Array.Empty<string>();
public long? GoodsId { get; set; }
public long BeforeId { get; set; }
public int Size { get; set; } = 50;
}
public class GoodsViolationLogEntity
{
public long Id { get; set; }
public string? ShopId { get; set; }
public long GoodsId { get; set; }
public int ReasonId { get; set; }
public ViolationLogAction Action { get; set; }
public DateTime CreatedTime { get; set; }
}
/// <summary>
/// 违规商品上报项。
/// </summary>
public class ReportGoodsViolationItem
{
[JsonProperty("goodsId")]
public long GoodsId { get; set; }
[JsonProperty("reasons")]
public string Reasons { get; set; } = string.Empty;
}
/// <summary>
/// 单个商品的违规上报结果。
/// </summary>
public class ReportGoodsViolationResult
{
[JsonProperty("goodsId")]
public long GoodsId { get; set; }
[JsonProperty("isOffShelf")]
public bool IsOffShelf { get; set; }
}
/// <summary>
/// 商品违规状态。
/// </summary>
public class GoodsViolationStatus
{
[JsonProperty("reasonIds")]
public int[] ReasonIds { get; set; }
[JsonProperty("goodsId")]
public long GoodsId { get; set; }
[JsonProperty("isViolation")]
public bool IsViolation { get; set; }
}
/// <summary>
/// 商品搜索请求
/// </summary>
public class GoodsSearchRequest
{
/// <summary>
/// 标题(模糊搜索)
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 最低价格(元)
/// </summary>
public long? MinPrice { get; set; }
/// <summary>
/// 最高价格(元)
/// </summary>
public long? MaxPrice { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
public int? CatId4 { get; set; }
/// <summary>
/// 最低销量
/// </summary>
public int? MinSoldQuantity { get; set; }
/// <summary>
/// 最高销量
/// </summary>
public int? MaxSoldQuantity { get; set; }
/// <summary>
/// 平台类型
/// </summary>
public PlatformType Platform { get; set; }
/// <summary>安全状态:0 全部(默认),1 安全,2 违规,3 未检测。</summary>
public int SafeStatus { get; set; }
/// <summary>
/// 页码(默认1)
/// </summary>
public int PageIndex { get; set; } = 1;
/// <summary>
/// 每页数量(默认20)
/// </summary>
public int PageSize { get; set; } = 20;
}
public class UserFavoritesGoodsSearchRequest : GoodsSearchRequest
{
public string UserName { get; set; }
}
/// <summary>
/// 商品搜索返回结果
/// </summary>
public class GoodsSearchResponse
{
/// <summary>
/// 商品列表
/// </summary>
[JsonProperty("list")]
public List<CloudGoods> List { get; set; } = new List<CloudGoods>();
/// <summary>
/// 总记录数
/// </summary>
[JsonProperty("total")]
public int Total { get; set; }
/// <summary>
/// 当前页码
/// </summary>
[JsonProperty("pageIndex")]
public int PageIndex { get; set; }
/// <summary>
/// 每页数量
/// </summary>
[JsonProperty("pageSize")]
public int PageSize { get; set; }
/// <summary>
/// 总页数
/// </summary>
[JsonProperty("totalPages")]
public int TotalPages { get; set; }
}
public class CloudGoods : BindableBase
{
public bool IsSelected
{
get => GetValue<bool>();
set => SetValue(value);
}
/// <summary>
/// 自增ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 平台类型
/// </summary>
public byte Platform { get; set; }
/// <summary>
/// 商品编号
/// </summary>
public long GoodsId { get; set; }
/// <summary>
/// 标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 缩略图
/// </summary>
public string? ThumbUrl { get; set; }
/// <summary>
/// 一级类目ID
/// </summary>
public int? CatId1 { get; set; }
/// <summary>
/// 二级类目ID
/// </summary>
public int? CatId2 { get; set; }
/// <summary>
/// 三级类目ID
/// </summary>
public int? CatId3 { get; set; }
/// <summary>
/// 四级类目ID
/// </summary>
public int? CatId4 { get; set; }
/// <summary>
/// 最低价快照(元),导入时计算
/// </summary>
public decimal MinPrice { get; set; }
/// <summary>
/// 最高价快照(元),导入时计算
/// </summary>
public decimal MaxPrice { get; set; }
public string Price => $"{MinPrice / 100m:N2}";
/// <summary>
/// 销量
/// </summary>
public int SoldQuantity { get; set; }
/// <summary>
/// 状态:0正常 1已删除
/// </summary>
public byte Status { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 更新时间(自动更新)
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 索引同步状态:0未同步 1已同步
/// </summary>
public byte IndexStatus { get; set; }
/// <summary>
/// 安全检测
/// </summary>
public bool? IsSafe { get; set; }
[JsonIgnore]
public string? DetectionResult
{
get => GetValue<string>();
set => SetValue(value);
}
}
}