增加 图片识别 缓存,缓存日期有等级
This commit is contained in:
1 parent
150e8644b7
commit
9bb17a67d2
5 files changed
+176
-19
No files matched your search
@@ -1,4 +1,5 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Entities;
|
||||
|
||||
@@ -14,4 +15,31 @@ public sealed class ImageAnalysisCache
|
||||
public string RecognitionResult { get; set; } = "";
|
||||
/// <summary>缓存最近更新时间,UTC。</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>从 UpdatedAt 起算的缓存有效小时数,命中时不续期。</summary>
|
||||
public int ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>每轮成功识别后更新有效期:空结果逐级延长,有内容则重置级别。</summary>
|
||||
public void RecordExpiration(bool isEmpty, DateTime utcNow)
|
||||
{
|
||||
// 新记录和上次有内容的记录,首次为空时均从 24 小时起算。
|
||||
ExpiresAt = isEmpty ? ExpiresAt switch
|
||||
{
|
||||
24 => 48,
|
||||
48 => 96,
|
||||
96 => 192,
|
||||
192 or 384 => 384,
|
||||
_ => 24
|
||||
} : 720;
|
||||
UpdatedAt = utcNow;
|
||||
}
|
||||
|
||||
/// <summary>仅复用有效期内的数组,旧对象格式需要重新识别。</summary>
|
||||
public string[]? ReadFreshResult(DateTime utcNow)
|
||||
{
|
||||
if (utcNow >= UpdatedAt.AddHours(ExpiresAt) || !RecognitionResult.TrimStart().StartsWith('['))
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<string[]>(RecognitionResult);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ CloudProduct 提供以下 POST 接口,成功时 ApiResult 的 code 为 200。
|
||||
|
||||
## 配置与部署
|
||||
|
||||
图片识别沿用专用数据库。将旧插件配置中的 `ProductInspection` 节迁入 CloudProduct 对应环境的配置文件,环境变量前缀由 `Plugins__ProductInspection__` 改为 `Plugins__CloudProduct__`。例如,密钥使用 `Plugins__CloudProduct__ProductInspection__ApiKey`,为空时读取 `DASHSCOPE_API_KEY`。部署时移出旧的 `Plugins/Store.ProductInspection` 目录,避免宿主重复加载质检功能。
|
||||
图片识别缓存使用插件的 `CloudProductConnection` 数据库连接。将旧插件配置中的 `ProductInspection` 节迁入 CloudProduct 对应环境的配置文件,环境变量前缀由 `Plugins__ProductInspection__` 改为 `Plugins__CloudProduct__`。例如,密钥使用 `Plugins__CloudProduct__ProductInspection__ApiKey`,为空时读取 `DASHSCOPE_API_KEY`。部署时移出旧的 `Plugins/Store.ProductInspection` 目录,避免宿主重复加载质检功能。
|
||||
|
||||
服务端将客户端提交的 MD5 转为小写,命中缓存后直接返回结果。未命中时调用配置的千问接口,最大并发数由 `ProductInspection:MaxConcurrentAnalysis` 控制。
|
||||
|
||||
@@ -19,4 +19,6 @@ CloudProduct 提供以下 POST 接口,成功时 ApiResult 的 code 为 200。
|
||||
|
||||
从仓库根目录运行 `dotnet run --project Tests/ProductInspection.Checks`,检查专用库结构并通过本地模拟模型验证图片识别和缓存。数据库连接可用 `Plugins__CloudProduct__ProductInspection__ConnectionString` 覆盖。
|
||||
|
||||
已有可用缓存表的部署只需迁移插件配置。旧库升级命令 `dotnet run --project Tests/ProductInspection.Checks -- --initialize` 会更名旧结果列,移除 DetectionText、LogoBrands 列及四张旧任务相关表,并同步缓存表结构。执行前请备份数据库,先归档或清理仍使用 SHA-256 键的缓存。
|
||||
已有缓存表升级时,先暂停旧服务写入,从仓库根目录运行 `dotnet run --project Tests/ProductInspection.Checks -- --migrate-cache-expiration`,再启动新版本。命令读取 `CloudProductConnection`,可通过 `Plugins__CloudProduct__CloudProductConnection` 指定目标库。数据库的 `ExpiresAt` 保存整数小时数,连续空结果的下一档由上一次的小时数决定。迁移保留原更新时间,将旧记录回填为对应小时数。服务器 UTC 时间达到 `UpdatedAt.AddHours(ExpiresAt)` 时缓存过期。空结果从 1 天开始,连续为空时逐级延长至 2、4、8、16 天并封顶。有内容的结果缓存 30 天,之后首次为空时重新从 1 天开始。
|
||||
|
||||
旧库升级命令 `dotnet run --project Tests/ProductInspection.Checks -- --initialize` 会更名旧结果列,移除 DetectionText、LogoBrands 列及四张旧任务相关表,并同步缓存表结构。执行前请备份数据库,先归档或清理仍使用 SHA-256 键的缓存。
|
||||
@@ -14,15 +14,18 @@ public sealed class ImageAnalysisQuotaService : IDisposable
|
||||
{
|
||||
private readonly HttpClient client;
|
||||
private readonly PluginContext<CloudProductPlugin> context;
|
||||
|
||||
private readonly string ApiKey;
|
||||
/// <summary>从站点根地址创建客户端,定期更新连接以跟随 DNS 变化。</summary>
|
||||
public ImageAnalysisQuotaService(PluginContext<CloudProductPlugin> context)
|
||||
{
|
||||
this.context = context;
|
||||
var url = context.RequireConfiguration("ImageAnalysisQuota:Url");
|
||||
ApiKey = context.RequireConfiguration("ImageAnalysisQuota:APIKey");
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var endpoint) ||
|
||||
(endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
|
||||
throw new InvalidOperationException("ImageAnalysisQuota:Url 必须是 HTTP 或 HTTPS 站点地址。");
|
||||
if (string.IsNullOrEmpty(ApiKey))
|
||||
throw new InvalidOperationException("ApiKey 不能为空");
|
||||
|
||||
client = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
@@ -46,7 +49,7 @@ public sealed class ImageAnalysisQuotaService : IDisposable
|
||||
// 外部协议要求 token 和 username 放在查询字符串中,统一编码特殊字符。
|
||||
var requestUrl = QueryHelpers.AddQueryString("/wxxd_user/UseImageAnalysisQuota", new Dictionary<string, string?>
|
||||
{
|
||||
["token"] = context.RequireConfiguration("ImageAnalysisQuota:APIKey"),
|
||||
["token"] = ApiKey,
|
||||
["username"] = request.UserName
|
||||
});
|
||||
using var response = await client.PostAsync(requestUrl, null, cancellationToken);
|
||||
@@ -60,6 +63,36 @@ public sealed class ImageAnalysisQuotaService : IDisposable
|
||||
return true;
|
||||
}
|
||||
throw new AppException(result.Msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定用户的识别额度,返回外部接口原始响应;HTTP 失败直接抛出异常。
|
||||
/// 额度扣减不自动重试,避免请求已执行但响应丢失时重复扣减。
|
||||
/// </summary>
|
||||
public async Task<int> GetUserImageAnalysisQuota(UseImageAnalysisQuotaRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestValidator.Validate(request);
|
||||
// 外部协议要求 token 和 username 放在查询字符串中,统一编码特殊字符。
|
||||
var requestUrl = QueryHelpers.AddQueryString("/wxxd_user/GetUserImageAnalysisQuota", new Dictionary<string, string?>
|
||||
{
|
||||
["token"] = ApiKey,
|
||||
["username"] = request.UserName
|
||||
});
|
||||
using var response = await client.PostAsync(requestUrl, null, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// shortcut: 尚未提供响应协议,明确业务成功字段后再添加响应 DTO 和业务失败判断。
|
||||
string html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
var result = JsonSerializer.Deserialize<ApiResult<int>>(html, options);
|
||||
if (result != null && result.Success)
|
||||
{
|
||||
return result.Data;
|
||||
}
|
||||
throw new AppException(result.Msg);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Security;
|
||||
using MAX.Framework.Validation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -52,24 +53,43 @@ public sealed class ProductInspectionService(IInspectionIntegration integration,
|
||||
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
|
||||
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();
|
||||
await database.InsertOrUpdate<ImageAnalysisCache>().SetSource(new ImageAnalysisCache
|
||||
{
|
||||
CacheKey = md5,
|
||||
RecognitionResult = JsonSerializer.Serialize(result, CacheJsonOptions),
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
}).ExecuteAffrowsAsync();
|
||||
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<ImageAnalysisCache>().SetSource(entry).IfExistsDoNothing().ExecuteAffrowsAsync();
|
||||
else
|
||||
await database.Update<ImageAnalysisCache>().SetSource(entry)
|
||||
.Where(x => x.UpdatedAt == previousUpdate).ExecuteAffrowsAsync();
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user