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(); 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 DetectSelectedAsync(IReadOnlyList items, Func readProduct, Action 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>(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 ReadPending(string path) { if (!File.Exists(path)) return new List(); var items = JsonConvert.DeserializeObject>(File.ReadAllText(path)); if (items == null || items.Any(InvalidItem)) throw new InvalidDataException("待检测配置无效,请检查 " + path); return items; } private static void SavePending(string path, List 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 products, ComplianceOptions options, Action completed, Action 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 DetectProductAsync(Product product, ComplianceOptions options, string[] words, Action 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(); 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 SelectImages(IEnumerable urls, int? count) => (urls ?? Enumerable.Empty()).Where(url => !string.IsNullOrWhiteSpace(url)).Select(url => url.Trim()) .Distinct(StringComparer.Ordinal).Take(count ?? int.MaxValue); private static async Task 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; } }