Files
KJD/Work/Services/GoodsComplianceService.cs
T
2026-11-09 10:50:48 +08:00

311 lines
12 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.Helper;
using AllBase;
using AllBase.Model;
using Work.Store.Api;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Net.Http;
using System.Linq;
using System.Text;
namespace Work.Services;
public sealed class ComplianceGoods
{
public const string Cloud = "云端收藏";
public const string Library = "我的商品库";
public string id { get; set; }
public string name { get; set; }
public string image { get; set; }
public string type { get; set; }
public byte? platform { get; set; }
}
public sealed class ComplianceResult
{
public string Text { get; internal set; }
public string[] Hits { get; internal set; } = Array.Empty<string>();
public int HitImageNumber { get; internal set; }
public string Error { get; internal set; }
public string Status => Error != null ? "失败,可重试" : Hits.Length == 0 ? "安全" : "有风险";
public string Summary => Error != null ? "失败:" + Error :
Hits.Length == 0 ? Status : $"有风险:{HitSource}命中关键词:{string.Join(",", Hits)}";
private string HitSource => HitImageNumber == 0 ? "标题" : $"第 {HitImageNumber} 张图片";
}
public sealed class ComplianceBatchResult
{
public int Safe { get; internal set; }
public int Risk { get; internal set; }
public int Failed { get; internal set; }
public int Unfinished { get; internal set; }
}
public static class GoodsComplianceService
{
private static readonly HttpClient ImageClient = new HttpClient(new HttpClientHandler { UseCookies = false })
{
Timeout = TimeSpan.FromSeconds(30),
MaxResponseContentBufferSize = 32 * 1024 * 1024
};
private static readonly SemaphoreSlim BatchGate = new SemaphoreSlim(1, 1);
public static async Task<ComplianceBatchResult> DetectSelectedAsync(IReadOnlyList<ComplianceGoods> items,
Func<ComplianceGoods, Product> readProduct, Action<string, string> update, CancellationToken cancellationToken = default)
{
if (items == null || items.Count == 0) throw new ArgumentException("请选择商品。", nameof(items));
if (readProduct == null) throw new ArgumentNullException(nameof(readProduct));
if (update == null) throw new ArgumentNullException(nameof(update));
if (!BatchGate.Wait(0)) throw new InvalidOperationException("已有商品正在检测。");
try
{
var options = GlobalConfig.GlobalSettings.GoodsCompliance;
var words = ValidateOptions(options);
var path = QueuePath();
var pending = ReadPending(path);
if (items.Any(InvalidItem)) throw new ArgumentException("缺少商品 ID 或来源。", nameof(items));
var selected = items.GroupBy(x => Tuple.Create(x.type, x.id)).Select(g => g.First()).ToList();
var existing = new HashSet<Tuple<string, string>>(pending.Select(x => Tuple.Create(x.type, x.id)));
pending.AddRange(selected.Where(x => existing.Add(Tuple.Create(x.type, x.id))));
SavePending(path, pending);
var batch = new ComplianceBatchResult { Unfinished = selected.Count };
foreach (ComplianceGoods item in selected)
{
if (cancellationToken.IsCancellationRequested) break;
update(item.id, "读取详情中");
ComplianceResult result;
try
{
//item.type
var product = await Task.Run(() => readProduct(item), cancellationToken);
result = await DetectProductAsync(product, options, words,
number => update(item.id, $"正在检测第 {number} 张图片"), cancellationToken);
if (result.Error == null)
{
try
{
if (item.type == ComplianceGoods.Library)
{
var url = BaseClass.BuildPDDObjString("product/SaveProductSafety");
var data = UserLogin.GetSignature();
data += "&user_name=" + BaseClass.UserName;
data += "&id=" + item.id;
string html = await GetHtml.PostJsonAsync(url, data);
}
else
{
await StoreApiClient.Instance.CloudGoods.SaveProductSafety(new SaveProductSafetyRequest
{
UserName = GetUserName(),
ProductId = product.ProductId,
IsSafe = result.Hits.Length == 0
});
}
}
catch (Exception ex)
{
result.Error = $"检测结果为{result.Status},上报失败:{ex.Message}";
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
update(item.id, "已停止");
break;
}
catch (Exception ex) { result = new ComplianceResult { Error = ex.Message }; }
if (result.Error == null)
{
var remaining = pending.Where(x => x.type != item.type || x.id != item.id).ToList();
try { SavePending(path, remaining); pending = remaining; }
catch (Exception ex) { result.Error = ex.Message; }
}
if (result.Error != null) batch.Failed++;
else if (result.Hits.Length > 0) batch.Risk++;
else batch.Safe++;
batch.Unfinished--;
update(item.id, result.Summary);
}
return batch;
}
finally { BatchGate.Release(); }
}
private static string QueuePath()
{
var username = BaseClass.UserName;
if (string.IsNullOrWhiteSpace(username)) throw new InvalidOperationException("请先登录。");
var user = Convert.ToBase64String(Encoding.UTF8.GetBytes(username)).Replace('/', '_').Replace('+', '-');
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "config", "goods_compliance_" + user + ".json");
}
private static bool InvalidItem(ComplianceGoods item) =>
item == null || string.IsNullOrWhiteSpace(item.id) || string.IsNullOrWhiteSpace(item.type);
private static List<ComplianceGoods> ReadPending(string path)
{
if (!File.Exists(path)) return new List<ComplianceGoods>();
var items = JsonConvert.DeserializeObject<List<ComplianceGoods>>(File.ReadAllText(path));
if (items == null || items.Any(InvalidItem))
throw new InvalidDataException("待检测配置无效,请检查 " + path);
return items;
}
private static void SavePending(string path, List<ComplianceGoods> items)
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
var temp = path + ".tmp";
File.WriteAllText(temp, JsonConvert.SerializeObject(items));
if (File.Exists(path)) File.Replace(temp, path, null);
else File.Move(temp, path);
}
public static async Task DetectAsync(IReadOnlyList<Product> products, ComplianceOptions options,
Action<Product, ComplianceResult> completed, Action<Product> started = null)
{
if (products == null) throw new ArgumentNullException(nameof(products));
if (completed == null) throw new ArgumentNullException(nameof(completed));
var words = ValidateOptions(options);
foreach (var product in products)
{
started?.Invoke(product);
var result = await DetectProductAsync(product, options, words);
if (result.Error == null)
{
try
{
await StoreApiClient.Instance.CloudGoods.SaveProductSafety(new SaveProductSafetyRequest
{
UserName = GetUserName(),
ProductId = product.ProductId,
IsSafe = result.Hits.Length == 0
});
}
catch (Exception ex)
{
result.Error = $"检测结果为{result.Status},上报失败:{ex.Message}";
}
}
completed(product, result);
}
}
private static string[] ValidateOptions(ComplianceOptions options)
{
var words = Keywords(options?.Keywords);
if (words.Length == 0) throw new ArgumentException("请先在系统设置的「商品 AI 检测」中保存 AI 检测关键词。");
if (options.MainCount <= 0 || options.DetailCount < 0)
throw new ArgumentException("请检查检测关键词和图片数量。");
return words;
}
private static async Task<ComplianceResult> DetectProductAsync(Product product, ComplianceOptions options,
string[] words, Action<int> imageStarted = null, CancellationToken cancellationToken = default)
{
var result = new ComplianceResult();
try
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(product?.ProductId)) throw new InvalidDataException("缺少商品 ID。");
result.Hits = Matches(product.Title ?? string.Empty, words);
if (result.Hits.Length > 0)
{
result.Text = product.Title;
return result;
}
var urls = SelectImages(product.MainImages, options.MainCount)
.Concat(SelectImages(product.DetailImages, options.DetailCount));
if (options.IncludeSku) urls = urls.Concat(SelectImages(product.Skus?.Select(s => s?.ImageUrl), null));
var texts = new List<string>();
int imageNumber = 0;
foreach (var url in urls.Distinct(StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
imageNumber++;
imageStarted?.Invoke(imageNumber);
try
{
var jpeg = await DownloadInspectionImageAsync(url);
cancellationToken.ThrowIfCancellationRequested();
var lines = await StoreApiClient.Instance.CloudGoods.RecognizeAsync(BaseClass.GetMd5(url), jpeg);
BaseClass.ImageAnalysisQuota -= 1;
var imageText = string.Join(Environment.NewLine, lines);
texts.AddRange(lines);
result.Hits = Matches(imageText, words);
if (result.Hits.Length > 0)
{
result.HitImageNumber = imageNumber;
break;
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
throw new InvalidDataException($"第 {imageNumber} 张图片检测失败:{ex.Message}", ex);
}
}
cancellationToken.ThrowIfCancellationRequested();
if (imageNumber == 0) throw new InvalidDataException("所选检测范围内没有图片。");
result.Text = string.Join(Environment.NewLine, texts);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex) { result.Error = ex.Message; }
return result;
}
private static IEnumerable<string> SelectImages(IEnumerable<string> urls, int? count) =>
(urls ?? Enumerable.Empty<string>()).Where(url => !string.IsNullOrWhiteSpace(url)).Select(url => url.Trim())
.Distinct(StringComparer.Ordinal).Take(count ?? int.MaxValue);
private static async Task<byte[]> DownloadInspectionImageAsync(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
throw new InvalidDataException("图片链接必须为 HTTP 或 HTTPS 地址。");
var bytes = await ImageClient.GetByteArrayAsync(uri).ConfigureAwait(false);
const int maxSide = 600;
using (var input = new MemoryStream(bytes))
using (var source = Image.FromStream(input))
{
var scale = Math.Min(1d, (double)maxSide / Math.Max(source.Width, source.Height));
int width = Math.Max(1, (int)Math.Round(source.Width * scale));
int height = Math.Max(1, (int)Math.Round(source.Height * scale));
using var canvas = new Bitmap(width, height, PixelFormat.Format24bppRgb);
using var output = new MemoryStream();
using (var g = Graphics.FromImage(canvas))
{
g.Clear(Color.White);
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.DrawImage(source, new Rectangle(0, 0, width, height),
0, 0, source.Width, source.Height, GraphicsUnit.Pixel);
}
canvas.Save(output, ImageFormat.Jpeg);
return output.ToArray();
}
}
public static string[] Keywords(string value) =>
System.Text.RegularExpressions.Regex.Split(value ?? "", @"[,,;;|\s]+")
.Where(x => x.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
public static string[] Matches(string recognized, string[] keywords) =>
keywords.Where(x => recognized.IndexOf(x, StringComparison.OrdinalIgnoreCase) >= 0).ToArray();
public static string GetUserName()
{
return string.IsNullOrEmpty(BaseClass.ParentUserName) ? BaseClass.UserName : BaseClass.ParentUserName;
}
}