Files
StoreWeb/Plugins/CloudProduct/Services/ProductInspectionService.cs
T
2026-11-06 19:25:36 +08:00

94 lines
3.9 KiB
C#

using MAX.Core.DependencyInjection;
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;
/// <summary>商品检测业务入口,负责上传图片识别、MD5 缓存和当前用户的商品安全记录。</summary>
public sealed class ProductInspectionService(IInspectionIntegration integration, ICurrentUser user, ImageAnalysisQuotaService imageAnalysisQuotaService,
[FromKeyedServices("CloudProductConnection")] IFreeSql database,
[FromKeyedServices("ProductInspectionAnalysisGate")] SemaphoreSlim gate,
ILogger<ProductInspectionService> 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<string[]> 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<ImageAnalysisCache>().Where(x => x.CacheKey == md5).FirstAsync();
string[] result;
// 旧对象格式缺少逐项阅读顺序,重新识别成功后再覆盖。
if (cached?.RecognitionResult.TrimStart().StartsWith('[') == true)
result = JsonSerializer.Deserialize<string[]>(cached.RecognitionResult)!;
else
{
await gate.WaitAsync(cancellationToken);
try
{
using var stream = new MemoryStream(image, writable: false);
var recognized = await integration.AnalyzeAsync(stream, mediaType, cancellationToken);
result = recognized.Distinct(StringComparer.Ordinal).ToArray();
await database.InsertOrUpdate<ImageAnalysisCache>().SetSource(new ImageAnalysisCache
{
CacheKey = md5,
RecognitionResult = JsonSerializer.Serialize(result, CacheJsonOptions),
UpdatedAt = DateTime.UtcNow
}).ExecuteAffrowsAsync();
}
finally { gate.Release(); }
}
try
{
var useRet = await imageAnalysisQuotaService.UseImageAnalysisQuotaAsync(new UseImageAnalysisQuotaRequest
{
UserName = request.UserName
});
}
catch (Exception ex)
{
logger.LogError(ex, "调用图片识别扣费程序失败");
//扣费失败显示空
result = [];
}
return result;
}
}