using MAX.Core.DependencyInjection; using MAX.Framework.Commons; using MAX.Framework.Security; using MAX.Framework.Validation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Store.Modules.CloudProduct.Contracts; using Store.Modules.CloudProduct.Dtos; using Store.Modules.CloudProduct.Entities; using System.ComponentModel.DataAnnotations; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Unicode; namespace Store.Modules.CloudProduct.Services; /// 商品检测业务入口,负责上传图片识别、MD5 缓存和当前用户的商品安全记录。 public sealed class ProductInspectionService(IInspectionIntegration integration, ICurrentUser user, ImageAnalysisQuotaService imageAnalysisQuotaService, [FromKeyedServices("CloudProductConnection")] IFreeSql database, [FromKeyedServices("ProductInspectionAnalysisGate")] SemaphoreSlim gate, ILogger logger ) : IScopedDependency { public const int MaxBytes = 10 * 1024 * 1024; // 缓存 JSON 保留中文原文,字符串中的引号和控制字符仍由序列化器转义。 private static readonly JsonSerializerOptions CacheJsonOptions = new() { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) }; // 每三个图片字节编码为四个 Base64 字符,末组向上取整。 public const int MaxBase64Length = (MaxBytes + 2) / 3 * 4; public async Task InspectAsync(InspectImageRequest request, CancellationToken cancellationToken) { RequestValidator.Validate(request); if (request.Md5.Length != 32 || !request.Md5.All(char.IsAsciiHexDigit)) throw new ValidationException("md5 必须是 32 位十六进制缓存键。"); var md5 = request.Md5.ToLowerInvariant(); if (request.Image.Length > MaxBase64Length) throw new ValidationException("图片超过 10 MiB 或 Base64 文本过长。"); byte[] image; try { image = Convert.FromBase64String(request.Image); } catch (FormatException) { throw new ValidationException("image 必须是有效的 Base64 字符串。"); } if (image.Length is < 12 or > MaxBytes) throw new ValidationException("图片为空、格式无效或超过 10 MiB。"); // 依据文件签名识别允许的图片类型,避免信任客户端文件名和 MIME 类型。 string mediaType; if (image[0] == 0xff && image[1] == 0xd8 && image[2] == 0xff) mediaType = "image/jpeg"; else if (image.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 })) mediaType = "image/png"; else if (image.AsSpan(0, 4).SequenceEqual("RIFF"u8) && image.AsSpan(8, 4).SequenceEqual("WEBP"u8)) mediaType = "image/webp"; else throw new ValidationException("请上传 JPEG、PNG 或 WebP 图片。"); var cached = await database.Select().Where(x => x.CacheKey == md5).FirstAsync(); var result = cached?.ReadFreshResult(DateTime.UtcNow); if (result is null) { await gate.WaitAsync(cancellationToken); try { var useImageQuota = await imageAnalysisQuotaService.GetUserImageAnalysisQuota(new UseImageAnalysisQuotaRequest { UserName = request.UserName }); if (useImageQuota <= 0) { throw new AppException("AI检测可用数量为0"); } } catch (Exception ex) { logger.LogError(ex, "调用图片识别扣费程序失败"); throw new AppException("AI检测可用数量为0"); } try { using var stream = new MemoryStream(image, writable: false); var recognized = await integration.AnalyzeAsync(stream, mediaType, cancellationToken); result = recognized.Distinct(StringComparer.Ordinal).ToArray(); var entry = cached ?? new ImageAnalysisCache { CacheKey = md5 }; var previousUpdate = entry.UpdatedAt; entry.RecognitionResult = JsonSerializer.Serialize(result, CacheJsonOptions); entry.RecordExpiration(result.Length == 0, DateTime.UtcNow); // 同一缓存失效后可能同时触发多次识别,只有首个写入者推进级别。 if (cached is null) await database.InsertOrUpdate().SetSource(entry).IfExistsDoNothing().ExecuteAffrowsAsync(); else await database.Update().SetSource(entry) .Where(x => x.UpdatedAt == previousUpdate).ExecuteAffrowsAsync(); } finally { gate.Release(); } } try { var useRet = await imageAnalysisQuotaService.UseImageAnalysisQuotaAsync(new UseImageAnalysisQuotaRequest { UserName = request.UserName }); } catch (Exception ex) { logger.LogError(ex, "调用图片识别扣费程序失败"); //扣费失败显示空 result = []; } return result; } }