311 lines
12 KiB
C#
311 lines
12 KiB
C#
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;
|
||
}
|
||
}
|