diff --git a/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs b/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs
index 4c24625..27c3dbc 100644
--- a/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs
+++ b/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs
@@ -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; } = "";
/// 缓存最近更新时间,UTC。
public DateTime UpdatedAt { get; set; }
+
+ /// 从 UpdatedAt 起算的缓存有效小时数,命中时不续期。
+ public int ExpiresAt { get; set; }
+
+ /// 每轮成功识别后更新有效期:空结果逐级延长,有内容则重置级别。
+ 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;
+ }
+
+ /// 仅复用有效期内的数组,旧对象格式需要重新识别。
+ public string[]? ReadFreshResult(DateTime utcNow)
+ {
+ if (utcNow >= UpdatedAt.AddHours(ExpiresAt) || !RecognitionResult.TrimStart().StartsWith('['))
+ return null;
+
+ return JsonSerializer.Deserialize(RecognitionResult);
+ }
}
diff --git a/Plugins/CloudProduct/ProductInspection.md b/Plugins/CloudProduct/ProductInspection.md
index ffb2d5d..e280a33 100644
--- a/Plugins/CloudProduct/ProductInspection.md
+++ b/Plugins/CloudProduct/ProductInspection.md
@@ -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 键的缓存。
diff --git a/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs b/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs
index d80657a..6cda12e 100644
--- a/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs
+++ b/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs
@@ -14,15 +14,18 @@ public sealed class ImageAnalysisQuotaService : IDisposable
{
private readonly HttpClient client;
private readonly PluginContext context;
-
+ private readonly string ApiKey;
/// 从站点根地址创建客户端,定期更新连接以跟随 DNS 变化。
public ImageAnalysisQuotaService(PluginContext 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
{
- ["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);
+ }
+
+ ///
+ /// 使用指定用户的识别额度,返回外部接口原始响应;HTTP 失败直接抛出异常。
+ /// 额度扣减不自动重试,避免请求已执行但响应丢失时重复扣减。
+ ///
+ public async Task GetUserImageAnalysisQuota(UseImageAnalysisQuotaRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ RequestValidator.Validate(request);
+ // 外部协议要求 token 和 username 放在查询字符串中,统一编码特殊字符。
+ var requestUrl = QueryHelpers.AddQueryString("/wxxd_user/GetUserImageAnalysisQuota", new Dictionary
+ {
+ ["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>(html, options);
+ if (result != null && result.Success)
+ {
+ return result.Data;
+ }
+ throw new AppException(result.Msg);
}
}
diff --git a/Plugins/CloudProduct/Services/ProductInspectionService.cs b/Plugins/CloudProduct/Services/ProductInspectionService.cs
index c8726f2..7370fba 100644
--- a/Plugins/CloudProduct/Services/ProductInspectionService.cs
+++ b/Plugins/CloudProduct/Services/ProductInspectionService.cs
@@ -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().Where(x => x.CacheKey == md5).FirstAsync();
- string[] result;
- // 旧对象格式缺少逐项阅读顺序,重新识别成功后再覆盖。
- if (cached?.RecognitionResult.TrimStart().StartsWith('[') == true)
- result = JsonSerializer.Deserialize(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().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().SetSource(entry).IfExistsDoNothing().ExecuteAffrowsAsync();
+ else
+ await database.Update().SetSource(entry)
+ .Where(x => x.UpdatedAt == previousUpdate).ExecuteAffrowsAsync();
}
finally { gate.Release(); }
}
diff --git a/Tests/ProductInspection.Checks/Program.cs b/Tests/ProductInspection.Checks/Program.cs
index 8d56a57..f54a836 100644
--- a/Tests/ProductInspection.Checks/Program.cs
+++ b/Tests/ProductInspection.Checks/Program.cs
@@ -1,12 +1,86 @@
using System.Text.Json;
using Store.Modules.CloudProduct.Entities;
-using var config = JsonDocument.Parse(File.ReadAllText("Plugins/CloudProduct/appsettings.json"));
+// 使用固定时间验证过期边界,无需等待或连接数据库。
+var now = DateTime.UtcNow;
+var cache = new ImageAnalysisCache { UpdatedAt = now, RecognitionResult = "[]" };
+foreach (var days in new[] { 1, 2, 4, 8, 16, 16 })
+{
+ cache.RecordExpiration(true, now);
+ if (cache.ExpiresAt != days * 24
+ || cache.ReadFreshResult(now.AddDays(days).AddTicks(-1)) is not { Length: 0 }
+ || cache.ReadFreshResult(now.AddDays(days)) != null)
+ throw new Exception("连续空识别结果的缓存期限错误。");
+ now = now.AddDays(days);
+}
+cache.RecognitionResult = """["文字"]""";
+cache.RecordExpiration(false, now);
+if (cache.ExpiresAt != 720
+ || cache.ReadFreshResult(now.AddDays(30).AddTicks(-1)) is not ["文字"]
+ || cache.ReadFreshResult(now.AddDays(30)) != null)
+ throw new Exception("非空识别结果应重置级别并缓存三十天。");
+cache.RecognitionResult = "{}";
+if (cache.ReadFreshResult(now) != null)
+ throw new Exception("旧对象格式应立即重新识别。");
+cache.RecordExpiration(true, now.AddDays(30));
+if (cache.ExpiresAt != 24)
+ throw new Exception("非空结果后的首次空结果应重新从一天开始。");
+if (args.Contains("--cache-policy"))
+{
+ Console.WriteLine("PASS cache expiration checks");
+ return;
+}
+
+using var config = JsonDocument.Parse(File.ReadAllText("Plugins/CloudProduct/appsettings.json"), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
+const string tableQuery = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME";
+const string columnsQuery = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' ORDER BY ORDINAL_POSITION";
+if (args.Contains("--migrate-cache-expiration"))
+{
+ // 运行时缓存使用 CloudProductConnection;历史初始化命令仍针对原专用库。
+ var cacheConnection = Environment.GetEnvironmentVariable("Plugins__CloudProduct__CloudProductConnection")
+ ?? config.RootElement.GetProperty("CloudProductConnection").GetString();
+ using var cacheDb = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, cacheConnection).Build();
+ const string expirationTypeQuery = """
+ SELECT DATA_TYPE FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' AND COLUMN_NAME = 'ExpiresAt'
+ """;
+ var columns = await cacheDb.Ado.QueryAsync(columnsQuery);
+ var expirationType = (await cacheDb.Ado.QueryAsync(expirationTypeQuery)).SingleOrDefault();
+ Console.WriteLine($"Cache columns before migration: {string.Join(", ", columns)}; ExpiresAt: {expirationType ?? "absent"}");
+ if (!columns.Contains("RecognitionResult") || !columns.Contains("UpdatedAt"))
+ throw new InvalidOperationException("当前运行库缺少缓存表或基础列。");
+ if (expirationType is not (null or "int" or "datetime" or "timestamp"))
+ throw new InvalidOperationException("不支持的 ExpiresAt 字段类型。");
+ // 用临时列完成日期到时长的转换,回填完成后才一次性替换原列。
+ var hasDateColumn = expirationType is "datetime" or "timestamp";
+ var hoursColumn = hasDateColumn ? "ExpiresAtHours" : "ExpiresAt";
+ if (!columns.Contains(hoursColumn))
+ await cacheDb.Ado.ExecuteNonQueryAsync($"ALTER TABLE image_analysis_cache ADD {hoursColumn} INT NULL");
+ // 已有整数时长原样保留;旧日期列按原到期时间与更新时间的差值换算。
+ var initialHours = "IF(TRIM(RecognitionResult) = '[]', 24, 720)";
+ if (hasDateColumn)
+ initialHours = $"COALESCE(TIMESTAMPDIFF(HOUR, UpdatedAt, ExpiresAt), {initialHours})";
+ await cacheDb.Ado.ExecuteNonQueryAsync($"UPDATE image_analysis_cache SET {hoursColumn} = {initialHours} WHERE {hoursColumn} IS NULL");
+ if (hasDateColumn)
+ await cacheDb.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache DROP COLUMN ExpiresAt, CHANGE ExpiresAtHours ExpiresAt INT NOT NULL");
+ else if ((await cacheDb.Ado.QueryAsync("SELECT IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' AND COLUMN_NAME = 'ExpiresAt'")).Single() == "YES")
+ await cacheDb.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache MODIFY ExpiresAt INT NOT NULL");
+
+ var finalType = (await cacheDb.Ado.QueryAsync(expirationTypeQuery)).Single();
+ var invalidRows = Convert.ToInt64(await cacheDb.Ado.ExecuteScalarAsync("""
+ SELECT COUNT(*) FROM image_analysis_cache
+ WHERE ExpiresAt IS NULL OR ExpiresAt NOT IN (24, 48, 96, 192, 384, 720)
+ """));
+ if (finalType != "int" || invalidRows != 0)
+ throw new InvalidOperationException($"迁移校验失败,ExpiresAt 类型 {finalType},异常行数 {invalidRows}。");
+ if (columns.Contains("EmptyResultStreak"))
+ await cacheDb.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache DROP COLUMN EmptyResultStreak");
+ Console.WriteLine("PASS database migration: cache lifetime stored in ExpiresAt hours");
+ return;
+}
var connection = Environment.GetEnvironmentVariable("Plugins__CloudProduct__ProductInspection__ConnectionString")
?? config.RootElement.GetProperty("ProductInspection").GetProperty("ConnectionString").GetString();
using var db = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection).Build();
-const string tableQuery = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME";
-const string columnsQuery = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' ORDER BY ORDINAL_POSITION";
if (args.Contains("--initialize"))
{
// 旧表只移除两个派生字段,保留缓存键、响应原文及更新时间。
@@ -30,7 +104,7 @@ var cacheColumns = await db.Ado.QueryAsync(columnsQuery);
Console.WriteLine("Cache columns: " + string.Join(", ", cacheColumns));
Console.WriteLine($"Cache rows: {await db.Select().CountAsync()}");
if (args.Contains("--schema")) return;
-if (!cacheColumns.ToHashSet().SetEquals(new[] { "CacheKey", "RecognitionResult", "UpdatedAt" }))
+if (!cacheColumns.ToHashSet().SetEquals(new[] { "CacheKey", "RecognitionResult", "UpdatedAt", "ExpiresAt" }))
throw new Exception("图片识别缓存结构不匹配。");
if (!tables.Contains("image_analysis_cache"))
throw new Exception("商品检测专用库缺少图片缓存表。");