云库增加 AI识图,缓存,我的云库收藏增加安全标志

调用外部API 扣费
This commit is contained in:
chfei committed 2026-11-06 19:25:36 +08:00
1 parent 7c29e57623
commit 150e8644b7
49 files changed
+1069 -28

No files matched your search

@@ -260,6 +260,10 @@ namespace MAX.Framework.DependencyInjection
provider.GetRequiredService<PluginRuntime>();
return context;
});
var typedContext = typeof(PluginContext<>).MakeGenericType(plugin.GetType());
host.Services.AddSingleton(typedContext, provider =>
// 复用键名注册,确保仅解析泛型上下文时也由容器管理配置的释放。
Activator.CreateInstance(typedContext, provider.GetRequiredKeyedService<PluginContext>(plugin.Id))!);
RunDebugStep("Plugin", $"注册 {plugin.Id} ({plugin.GetType().FullName}),Priority={plugin.Priority}",
() => plugin.ConfigureServices(host.Services, context));
}
+1
View File
@@ -15,6 +15,7 @@
设备数为 0 时无限制,为 1 时单点登录,为 N 时保留最多 N 个有效会话,超限撤销最早的会话。
签发在事务中锁定现有用户行,使多实例的并发登录也遵守数量限制。MySQL 业务用户表需使用 InnoDB。
退出当前会话、修改密码后的全部会话撤销以及设备替换统一更新 Revoked 并清除当前实例缓存。
宿主注册 Hangfire 任务 `token-cleanup`,每天北京时间 03:00(UTC 19:00)删除已撤销、闲置到期或达到绝对期限的记录;可在 `/hangfire` 查看执行结果。任务执行需要宿主运行。
多实例下其他实例最多延迟 CacheSeconds 秒获知撤销;要求每次查库时设置 CacheSeconds=0。
用户信息在缓存未命中时从实际 `wxxd.user` 表读取 id/user_name/nick_name;当前用户表没有 IsEnabled 或 Role 字段,因此未假设这些字段或新增角色策略。
@@ -131,6 +131,20 @@ public sealed class TokenService(IFreeSql db, IOptions<TokenAuthOptions> options
}
}
/// <summary>清理已撤销或已到期的会话,返回删除数量,供每日后台任务调用。</summary>
public int CleanupExpiredTokens()
{
lock (Gate)
{
var now = Now;
var cutoff = now.AddDays(-settings.AbsoluteMaxDays);
// 与认证的到期边界一致;直接条件删除,避免先查询再删除时误删已续期的会话。
return db.Delete<UserToken>()
.Where(t => t.Revoked || t.ExpiresAt <= now || t.CreatedAt <= cutoff)
.ExecuteAffrows();
}
}
internal void Revoke(string userName, string? sessionId = null)
{
lock (Gate)
+17
View File
@@ -37,3 +37,20 @@ public sealed class PluginContext : IDisposable
(Configuration as IDisposable)?.Dispose();
}
}
/// <summary>按插件类型注入的上下文视图,配置及其生命周期由宿主的 PluginContext 统一管理。</summary>
public sealed class PluginContext<TPlugin>(PluginContext context) where TPlugin : IPlugin
{
/// <summary>插件唯一标识。</summary>
public string Id => context.Id;
/// <summary>插件程序集和配置所在目录。</summary>
public string DirectoryPath => context.DirectoryPath;
/// <summary>当前宿主环境。</summary>
public IHostEnvironment Environment => context.Environment;
/// <summary>与插件入口共享的独立配置实例。</summary>
public IConfigurationRoot Configuration => context.Configuration;
/// <summary>读取必填配置,缺失时沿用插件上下文的错误说明。</summary>
public string RequireConfiguration(string key) => context.RequireConfiguration(key);
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\MAX.Plugins\MAX.Plugins.csproj" />
<ProjectReference Include="..\Store.Users.Contracts\Store.Users.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -7,6 +7,7 @@ namespace Store.Users.Contracts
{
public interface IUserQueryService
{
Task<UserInfo> GetMainUser(string userName);
Task<UserInfo> GetUser(string userName);
}
}
@@ -1,23 +1,23 @@
using MAX.Framework.Conventions;
using MAX.Framework.Dtos;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Store.Framework.Base;
using Store.Users.Commands;
using Store.Users.Contracts.Dtos;
using Store.Users.Dtos;
using Store.Users.Entities;
using Store.Users.Services;
using MAX.Framework.Dtos;
using MAX.Framework.Conventions;
namespace Store.Users.Controllers;
/// <summary>用户模块接口;所有管理操作均以当前登录身份对应的数据库账户进行授权。</summary>
[ApiController]
[DefaultHttpPost]
[Authorize]
[Route("v{version:apiVersion}/user/[action]")]
public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator) : ControllerBase
[Route("[controller]/[action]")]
public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator) : AppControllerBase
{
/// <summary>获取本人或授权范围内的用户信息,响应不包含密码。</summary>
[EndpointSummary("获取用户信息")]
@@ -5,22 +5,28 @@ namespace Store.Users.Repositories;
public interface IUserRepository : IBaseRepository<UserEntity>
{
/// <summary>获取用户及从主账号继承的有效配置;用户不存在时返回空。</summary>
Task<UserEntity?> GetUser(string userName);
/// <summary>获取用户所属主账号(主账号返回自身);用户不存在时返回空,主账号无效时抛出业务异常。</summary>
Task<UserEntity?> GetMainUser(string userName);
}
/// <summary>集中处理旧系统主子账号的有效计费配置和积分权限继承。</summary>
internal class UserRepository(IFreeSql freeSql) : BaseRepository<UserEntity>(freeSql), IUserRepository, ITransientDependency
{
/// <inheritdoc />
public async Task<UserEntity?> GetUser(string userName)
{
var user = await Where(u => u.UserName == userName).FirstAsync();
if (user == null || user.IsMainUser) return user;
var parent = await Where(u => u.UserName == user.ParentUserName).FirstAsync();
if (parent == null) throw new MAX.Framework.Commons.AppException("主账号不存在,请联系管理员");
var parent = await GetMainUser(user);
user.OemId = parent.OemId;
user.PostagePerOrder = Math.Max(2.2m, parent.PostagePerOrder ?? 0);
user.RefundPostagePerOrder = Math.Max(2.5m, parent.RefundPostagePerOrder ?? 0);
user.GiftOemAmountPerOrder = Math.Max(0, parent.GiftOemAmountPerOrder ?? 0);
// 子账号的计费配置完全跟随主账号,包括未配置的费用。
user.ShopPointsPerMonth = parent.ShopPointsPerMonth;
user.PostagePerOrder = parent.PostagePerOrder;
user.RefundPostagePerOrder = parent.RefundPostagePerOrder;
user.GiftOemAmountPerOrder = parent.GiftOemAmountPerOrder;
user.SyncLogisticsPerOrder = parent.SyncLogisticsPerOrder;
var authority = (user.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
var parentAuthority = (parent.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
@@ -28,4 +34,20 @@ internal class UserRepository(IFreeSql freeSql) : BaseRepository<UserEntity>(fre
user.Authority = string.Join(",", authority);
return user;
}
/// <inheritdoc />
public async Task<UserEntity?> GetMainUser(string userName)
{
var user = await Where(u => u.UserName == userName).FirstAsync();
return user == null ? null : await GetMainUser(user);
}
/// <summary>统一校验主子账号关系,禁止从其他子账号继承配置。</summary>
private async Task<UserEntity> GetMainUser(UserEntity user)
{
if (user.IsMainUser) return user;
var parent = await Where(u => u.UserName == user.ParentUserName).FirstAsync();
if (parent == null || !parent.IsMainUser)
throw new MAX.Framework.Commons.AppException("主账号不存在或账号关系无效,请联系管理员");
return parent;
}
}
@@ -16,6 +16,17 @@ internal sealed class UserQueryService(IUserRepository repository) : IUserQueryS
var user = await repository.GetUser(userName.Trim()) ?? throw new AppException("用户不存在");
return new UserMapper().ToInfo(user);
}
/// <summary>
/// 获取主账号
/// </summary>
/// <param name="userName"></param>
/// <returns></returns>
public async Task<UserInfo> GetMainUser(string userName)
{
RequestValidator.Validate(new UserNameRequest { UserName = userName?.Trim()! });
var user = await repository.GetMainUser(userName.Trim()) ?? throw new AppException("用户不存在");
return new UserMapper().ToInfo(user);
}
}
internal sealed class UserAuthenticationService(IFreeSql orm, IUserQueryService query)
@@ -33,6 +33,17 @@ namespace Store.Modules.CloudProduct
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connectionStringBuilder.ConnectionString)
.UseAdoConnectionPool(true).Build());
services.AddHostedService<CloudProductSearchStartup>();
// 服务自行创建客户端,单例复用连接池并由容器负责释放。
services.AddSingleton<ImageAnalysisQuotaService>();
// 图片识别沿用专用缓存库,所有请求共享并发上限。
var inspectionConnection = RequireConfiguration("ProductInspection:ConnectionString");
services.AddKeyedSingleton<IFreeSql>("ProductInspectionConnection", (_, _) =>
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, inspectionConnection)
.UseAdoConnectionPool(true).Build());
var maximum = Context.Configuration.GetValue<int?>("ProductInspection:MaxConcurrentAnalysis") ?? 5;
if (maximum is < 1 or > 100) throw new InvalidOperationException("识别并发数必须在 1 至 100 之间。");
services.AddKeyedSingleton<SemaphoreSlim>("ProductInspectionAnalysisGate", (_, _) => new SemaphoreSlim(maximum, maximum));
}
@@ -0,0 +1,7 @@
namespace Store.Modules.CloudProduct.Contracts;
/// <summary>按图片阅读顺序返回文字和中文品牌名,无识别项返回空数组。</summary>
public interface IInspectionIntegration
{
Task<string[]> AnalyzeAsync(Stream image, string mediaType, CancellationToken cancellationToken);
}
@@ -15,8 +15,7 @@ namespace Store.Modules.CloudProduct.Controllers
{
/// <summary>云商品管理:商品查询、导入、违规上报及搜索索引维护。</summary>
[Tags("云商品")]
[ApiVersionNeutral]
[Route("api/CloudGoods/[action]")]
[Route("CloudGoods/[action]")]
public class GoodsController : AppControllerBase
{
private readonly IGoodsService _goodsService;
@@ -0,0 +1,19 @@
using MAX.Framework.Conventions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Store.Framework.Base;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Services;
namespace Store.Modules.CloudProduct.Controllers;
/// <summary>上传图片识别及当前用户的商品安全记录。</summary>
[Tags("商品检测"), DefaultHttpPost]
[Route("api/CloudGoods/[action]")]
public sealed class ProductInspectionController(ProductInspectionService service) : AppControllerBase
{
[EndpointSummary("识别上传图片中的文字和品牌 Logo")]
[RequestFormLimits(ValueLengthLimit = ProductInspectionService.MaxBase64Length)]
public Task<string[]> InspectImage(InspectImageRequest request, CancellationToken cancellationToken)
=> service.InspectAsync(request, cancellationToken);
}
@@ -58,6 +58,14 @@ namespace Store.Modules.CloudProduct.Controllers
return await _favoritesService.AddFavoritesAsync(request);
}
/// <summary>更新用户已收藏商品的安全结论。</summary>
[HttpPost]
public async Task<ApiResult> SaveProductSafety(SaveProductSafetyRequest request)
{
await _favoritesService.SaveProductSafetyAsync(request);
return ApiResult.Ok();
}
/// <summary>
/// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。
/// </summary>
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace Store.Modules.CloudProduct.Dtos;
/// <summary>单张商品图片,使用 multipart/form-data 上传。</summary>
public sealed class InspectImageRequest
{
[Required] public string UserName { get; set; } = "";
/// <summary>客户端指定的 32 位十六进制 MD5 缓存键,大小写均可。</summary>
[Required] public string Md5 { get; set; } = "";
/// <summary>图片原始字节的 Base64 字符串,不含 Data URL 前缀。</summary>
[Required] public string Image { get; set; } = "";
}
///// <summary>在当前登录用户和指定平台内按商品 ID 批量查询。</summary>
//public sealed class QueryProductSafetyRequest
//{
// [Required] public string UserName { get; set; } = "";
// [Required] public string[] ProductIds { get; set; } = [];
//}
//public sealed record ProductSafetyDto(string ProductId, bool IsSafe);
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace Store.Modules.CloudProduct.Dtos;
/// <summary>保存收藏商品的检测结论,沿用原接口的字符串商品编号。</summary>
public sealed class SaveProductSafetyRequest
{
[Required] public string UserName { get; set; } = "";
[Required] public string ProductId { get; set; } = "";
[Required] public bool? IsSafe { get; set; }
}
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace Store.Modules.CloudProduct.Dtos;
/// <summary>外部网站图片识别额度扣减请求。</summary>
public sealed class UseImageAnalysisQuotaRequest
{
/// <summary>需要使用识别额度的外部网站用户名。</summary>
[Required(ErrorMessage = "用户名不能为空")]
public string UserName { get; set; } = string.Empty;
}
@@ -49,5 +49,9 @@ namespace Store.Modules.CloudProduct.Dtos
/// <summary>收藏时间</summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 安全检测
/// </summary>
public bool? IsSafe { get; set; }
}
}
@@ -9,6 +9,10 @@ namespace Store.Modules.CloudProduct.Dtos
/// </summary>
public class UserFavoritesSearchDocument
{
/// <summary>安全检测结论;为空表示尚未检测。</summary>
[Boolean(Name = "is_safe")]
public bool? IsSafe { get; set; }
/// <summary>
/// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id}
/// </summary>
@@ -5,6 +5,9 @@ namespace Store.Modules.CloudProduct.Dtos
/// </summary>
public class UserFavoritesSearchRequest
{
/// <summary>安全状态:0 全部(默认),1 安全,2 违规,3 未检测。</summary>
public int SafeStatus { get; set; }
/// <summary>
/// 用户名(为空时取当前登录用户)
/// </summary>
@@ -0,0 +1,17 @@
using FreeSql.DataAnnotations;
namespace Store.Modules.CloudProduct.Entities;
/// <summary>跨用户复用的图片识别数据,仅存储成功识别结果。</summary>
[Table(Name = "image_analysis_cache")]
public sealed class ImageAnalysisCache
{
/// <summary>客户端指定的 MD5 缓存键,统一保存为 32 位小写十六进制。</summary>
[Column(IsPrimary = true, StringLength = 32)]
public string CacheKey { get; set; } = "";
/// <summary>模型识别结果的 JSON 字符串数组,空数组表示未识别到文字或品牌。</summary>
[Column(DbType = "longtext")]
public string RecognitionResult { get; set; } = "";
/// <summary>缓存最近更新时间,UTC。</summary>
public DateTime UpdatedAt { get; set; }
}
@@ -34,6 +34,10 @@ namespace Store.Modules.CloudProduct.Entities
[Column(Name = "goods_id", IsNullable = false)]
public long GoodsId { get; set; }
/// <summary>最近上报的安全结论,null 表示尚未检测。</summary>
[Column(Name = "is_safe", IsNullable = true)]
public bool? IsSafe { get; set; }
/// <summary>
/// 收藏时间
/// </summary>
+22
View File
@@ -0,0 +1,22 @@
# 商品图片识别
CloudProduct 提供以下 POST 接口,成功时 ApiResult 的 code 为 200。
| 路径 | JSON 请求 | 成功响应的 data |
|---|---|---|
| `/api/ProductInspection/InspectImage` | `userName`、`md5`、`image` 均必填。md5 为 32 位十六进制缓存键,image 为 JPEG、PNG 或 WebP 原图的纯 Base64(不含 Data URL 前缀),原图最多 10 MiB | 按阅读顺序去重的文字和品牌数组,无识别项时返回 `[]` |
| `/api/CloudGoods/SaveProductSafety` | `{"userName":"alice","productId":"123","isSafe":false}` | 无数据 |
客户端根据识别结果判断商品是否安全,再上报已收藏商品的结论。最新结论可从收藏检索接口读取。
## 配置与部署
图片识别沿用专用数据库。将旧插件配置中的 `ProductInspection` 节迁入 CloudProduct 对应环境的配置文件,环境变量前缀由 `Plugins__ProductInspection__` 改为 `Plugins__CloudProduct__`。例如,密钥使用 `Plugins__CloudProduct__ProductInspection__ApiKey`,为空时读取 `DASHSCOPE_API_KEY`。部署时移出旧的 `Plugins/Store.ProductInspection` 目录,避免宿主重复加载质检功能。
服务端将客户端提交的 MD5 转为小写,命中缓存后直接返回结果。未命中时调用配置的千问接口,最大并发数由 `ProductInspection:MaxConcurrentAnalysis` 控制。
## 验证
从仓库根目录运行 `dotnet run --project Tests/ProductInspection.Checks`,检查专用库结构并通过本地模拟模型验证图片识别和缓存。数据库连接可用 `Plugins__CloudProduct__ProductInspection__ConnectionString` 覆盖。
已有可用缓存表的部署只需迁移插件配置。旧库升级命令 `dotnet run --project Tests/ProductInspection.Checks -- --initialize` 会更名旧结果列,移除 DetectionText、LogoBrands 列及四张旧任务相关表,并同步缓存表结构。执行前请备份数据库,先归档或清理仍使用 SHA-256 键的缓存。
@@ -11,7 +11,12 @@ namespace Store.Modules.CloudProduct.Services;
/// <summary>跨实例串行化违规上报和商品索引写入,防止下架后被旧批次写回。</summary>
public class GoodsIndexLock([FromKeyedServices("CloudProduct")] PluginContext context) : ISingletonDependency
{
public async Task<IAsyncDisposable> AcquireAsync()
public Task<IAsyncDisposable> AcquireAsync() => AcquireAsync("goods-index:");
/// <summary>收藏重建、整条文档写入和安全状态更新共用独立锁,防止旧快照覆盖新结论。</summary>
public virtual Task<IAsyncDisposable> AcquireFavoritesAsync() => AcquireAsync("favorites-index:");
private async Task<IAsyncDisposable> AcquireAsync(string prefix)
{
// 禁用连接池:即使释放锁失败,关闭物理连接也会释放会话锁。
var settings = new MySqlConnectionStringBuilder(context.Configuration["CloudProductConnection"])
@@ -19,7 +24,7 @@ public class GoodsIndexLock([FromKeyedServices("CloudProduct")] PluginContext co
Pooling = false
};
var connection = new MySqlConnection(settings.ConnectionString);
var name = "goods-index:" + Convert.ToHexString(SHA256.HashData(
var name = prefix + Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes(settings.Database)))[..40];
try
{
@@ -0,0 +1,65 @@
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Validation;
using MAX.Plugins;
using Microsoft.AspNetCore.WebUtilities;
using Store.Modules.CloudProduct.Dtos;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Store.Modules.CloudProduct.Services;
/// <summary>调用外部网站的图片识别额度接口,单例复用 HTTP 连接。</summary>
public sealed class ImageAnalysisQuotaService : IDisposable
{
private readonly HttpClient client;
private readonly PluginContext<CloudProductPlugin> context;
/// <summary>从站点根地址创建客户端,定期更新连接以跟随 DNS 变化。</summary>
public ImageAnalysisQuotaService(PluginContext<CloudProductPlugin> context)
{
this.context = context;
var url = context.RequireConfiguration("ImageAnalysisQuota:Url");
if (!Uri.TryCreate(url, UriKind.Absolute, out var endpoint) ||
(endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
throw new InvalidOperationException("ImageAnalysisQuota:Url 必须是 HTTP 或 HTTPS 站点地址。");
client = new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
AllowAutoRedirect = false
})
{ BaseAddress = endpoint };
}
/// <summary>随服务释放客户端及连接池。</summary>
public void Dispose() => client.Dispose();
/// <summary>
/// 使用指定用户的识别额度,返回外部接口原始响应;HTTP 失败直接抛出异常。
/// 额度扣减不自动重试,避免请求已执行但响应丢失时重复扣减。
/// </summary>
public async Task<bool> UseImageAnalysisQuotaAsync(UseImageAnalysisQuotaRequest request,
CancellationToken cancellationToken = default)
{
RequestValidator.Validate(request);
// 外部协议要求 token 和 username 放在查询字符串中,统一编码特殊字符。
var requestUrl = QueryHelpers.AddQueryString("/wxxd_user/UseImageAnalysisQuota", new Dictionary<string, string?>
{
["token"] = context.RequireConfiguration("ImageAnalysisQuota: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 result = JsonSerializer.Deserialize<ApiResult>(html);
if (result != null && result.Success)
{
return true;
}
throw new AppException(result.Msg);
}
}
@@ -0,0 +1,93 @@
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;
}
}
@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MAX.Core.DependencyInjection;
using MAX.Plugins;
using Store.Modules.CloudProduct.Contracts;
namespace Store.Modules.CloudProduct.Services;
/// <summary>通过千问兼容接口识别上传图片中的文字及品牌。</summary>
public sealed class QwenInspectionIntegration : IInspectionIntegration, ISingletonDependency, IDisposable
{
private readonly HttpClient client;
private readonly string model;
/// <summary>按插件配置创建并复用模型客户端,定期更新池内连接以跟随 DNS 变化。</summary>
public QwenInspectionIntegration(PluginContext<CloudProductPlugin> context)
{
model = context.RequireConfiguration("ProductInspection:Model");
var endpoint = new Uri(context.RequireConfiguration("ProductInspection:Endpoint"));
var key = context.Configuration["ProductInspection:ApiKey"];
if (string.IsNullOrWhiteSpace(key)) key = Environment.GetEnvironmentVariable("DASHSCOPE_API_KEY");
client = new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
//Proxy = new WebProxy("http://127.0.0.1:8080")
})
{
BaseAddress = endpoint,
Timeout = TimeSpan.FromMinutes(10)
};
if (!string.IsNullOrWhiteSpace(key))
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
}
public void Dispose() => client.Dispose();
private const string Prompt = """
你是图像品牌与文字识别引擎。请识别图片中的文字和品牌 Logo,并严格按下方规则输出。
【识别范围】
- 所有可见文字(标题、正文、按钮文字、标签、说明等),原样识别输出。
- 品牌 Logo:识别 Logo 对应的品牌,输出品牌中文名(不输出 Logo 上的原始英文/字母)。
- 图片、图标、二维码、条形码、分隔线等非文字非品牌元素:忽略,不输出。
【Logo 处理规则】
- 遇到品牌 Logo 时,不要输出 Logo 上的原始英文/字母。
- 必须输出该品牌对应的中文品牌名。
- 示例:
* 看到 "ERKE" 或鸿鹄图形 → 输出 "鸿星尔克"
* 看到 "Nike" 或钩子图形 → 输出 "耐克"
* 看到 "Adidas" 或三叶草/三条纹 → 输出 "阿迪达斯"
* 看到 "Li-Ning" 或李宁图形 → 输出 "李宁"
* 看到 "ANTA" 或安踏图形 → 输出 "安踏"
* 看到 "Xtep" 或特步图形 → 输出 "特步"
* 看到 "361°" → 输出 "361度"
- 如遇无法识别为已知品牌的 Logo,输出 "未知品牌"。
- 如 Logo 上同时有中英文,优先输出中文品牌名。
【品牌名归一化规则(重要)】
- 输出的品牌必须是一个独立、纯净的品牌名,不能包含子品牌、系列名、品类词、修饰词,也不能用连接符拼接。
- 子品牌或系列要归并到主品牌,只输出主品牌名。
- 正例:
* "安踏儿童" → 输出 "安踏"(不要输出 "安踏儿童")
* "安踏冠军系列" → 输出 "安踏"
* "Nike Air" / "耐克 Air" → 输出 "耐克"
* "Adidas Originals" / "阿迪达斯三叶草" → 输出 "阿迪达斯"
* "李宁中国李宁" → 输出 "李宁"
* "鸿星尔克ERKE" → 输出 "鸿星尔克"
- 反例(禁止输出):
* "安踏儿童"、"安踏-儿童"、"安踏 儿童"、"安踏(儿童)"
* "耐克/Nike"、"阿迪达斯 Adidas"
- 禁止使用 "/"、"-"、"·"、空格、括号等连接符拼接多个名称。
【输出要求】
1. 只输出一个 JSON 字符串数组,不要输出对象、markdown 代码块、解释性文字或多余内容。
2. 格式固定为:["文字1", "文字2", "文字3", ...]
3. 按从上到下、从左到右的阅读顺序排列。
4. 每个元素只包含文字本身,不包含类型、坐标等额外字段。
5. 文字中的双引号需转义(\"),确保输出是合法 JSON。
6. 若图中没有任何可识别文字或品牌,输出 []。
""";
public async Task<string[]> AnalyzeAsync(Stream image, string mediaType, CancellationToken cancellationToken)
{
if (client.DefaultRequestHeaders.Authorization is null)
throw new InvalidOperationException("请配置 ProductInspection:ApiKey 或 DASHSCOPE_API_KEY。");
using var buffer = new MemoryStream();
await image.CopyToAsync(buffer, cancellationToken);
var url = $"data:{mediaType};base64,{Convert.ToBase64String(buffer.GetBuffer(), 0, (int)buffer.Length)}";
using var response = await client.PostAsJsonAsync("", new
{
model,
messages = new[] { new { role = "user", content = new object[]
{
new { type = "image_url", image_url = new { url } },
new { type = "text", text = Prompt }
} } },
stream = false,
enable_thinking = false
}, cancellationToken);
response.EnsureSuccessStatusCode();
using var body = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken);
var content = body!.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();
var words = JsonSerializer.Deserialize<string[]>(content ?? throw new JsonException("模型未返回识别内容。"));
if (words == null || words.Any(word => word == null)) throw new JsonException("模型必须返回 JSON 字符串数组。");
return words;
}
}
@@ -33,6 +33,9 @@ namespace Store.Modules.CloudProduct.Services
/// <summary>批量写入收藏文档(存在则覆盖)</summary>
Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents);
/// <summary>更新单条收藏的安全字段并等待对检索可见。</summary>
Task UpdateSafetyAsync(string documentId, bool isSafe);
/// <summary>按文档Id批量删除收藏文档</summary>
Task DeleteDocumentsAsync(IEnumerable<string> documentIds);
@@ -172,6 +175,7 @@ namespace Store.Modules.CloudProduct.Services
)
.Map<UserFavoritesSearchDocument>(m => m
.Properties(p => p
.Boolean(b => b.Name(x => x.IsSafe))
.Keyword(k => k.Name(x => x.Id))
.Number(n => n.Name(x => x.FavId).Type(NumberType.Long))
.Keyword(k => k.Name(x => x.UserName))
@@ -221,6 +225,14 @@ namespace Store.Modules.CloudProduct.Services
return await CreateIndexAsync();
}
public async Task UpdateSafetyAsync(string documentId, bool isSafe)
{
var response = await _client.UpdateAsync<UserFavoritesSearchDocument, object>(documentId, u => u
.Index(_indexName).Doc(new { is_safe = isSafe }).Refresh(Refresh.WaitFor));
if (!response.IsValid)
throw new AppException("更新收藏安全状态索引失败: " + response.DebugInformation);
}
private async Task RefreshIndexAsync()
{
var response = await _client.Indices.RefreshAsync(_indexName, d => d
@@ -316,6 +328,9 @@ namespace Store.Modules.CloudProduct.Services
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
if (request.SafeStatus is < 0 or > 3)
throw new AppException("状态必须为 0(全部)、1(安全)、2(违规)或 3(未检测)", ResultCode.ValidationError);
var keyword = request.Title;
int? groupId = request.GroupId;
var groupName = request.GroupName;
@@ -341,6 +356,12 @@ namespace Store.Modules.CloudProduct.Services
// 只检索当前用户自己的收藏 —— 放进 filters,避免被后面的 Filter 覆盖
filters.Add(f => f.Term(t => t.Field(x => x.UserName).Value(request.UserName)));
// is_safe 为 null 或缺失时没有索引值,两者均表示暂无检测结论。
if (request.SafeStatus is 1 or 2)
filters.Add(f => f.Term(t => t.Field(x => x.IsSafe).Value(request.SafeStatus == 1)));
else if (request.SafeStatus == 3)
filters.Add(f => f.Bool(b => b.MustNot(q => q.Exists(e => e.Field(x => x.IsSafe)))));
if (groupId.HasValue)
{
filters.Add(f => f.Term(t => t.Field(x => x.GroupId).Value(groupId.Value)));
@@ -1,11 +1,14 @@
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Validation;
using Microsoft.Extensions.Logging;
using Store.Modules.CloudProduct.Dtos;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Enums;
using Store.Modules.CloudProduct.Repositorys;
using Store.Users.Contracts;
using System.Globalization;
namespace Store.Modules.CloudProduct.Services
{
@@ -17,6 +20,9 @@ namespace Store.Modules.CloudProduct.Services
/// <summary>批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组)</summary>
Task<int> AddFavoritesAsync(AddUserFavoriteRequest request);
/// <summary>按用户和商品编号更新已存在的收藏记录。</summary>
Task SaveProductSafetyAsync(SaveProductSafetyRequest request);
/// <summary>批量取消收藏,返回实际取消的条数</summary>
Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request);
@@ -47,6 +53,8 @@ namespace Store.Modules.CloudProduct.Services
private readonly UserGoodsFavoriteRepository _favoriteRepository;
private readonly UserGoodsFavoriteGroupRepository _groupRepository;
private readonly GoodsRepository _goodsRepository;
private readonly IUserQueryService _queryService;
private readonly GoodsIndexLock _indexLock;
private readonly IUserFavoritesIndexService _indexService;
private readonly ILogger<UserFavoritesService> _logger;
@@ -54,13 +62,16 @@ namespace Store.Modules.CloudProduct.Services
UserGoodsFavoriteRepository favoriteRepository,
UserGoodsFavoriteGroupRepository groupRepository,
GoodsRepository goodsRepository,
IUserQueryService queryService,
IUserFavoritesIndexService indexService,
ILogger<UserFavoritesService> logger)
ILogger<UserFavoritesService> logger, GoodsIndexLock indexLock)
{
_favoriteRepository = favoriteRepository;
_groupRepository = groupRepository;
_goodsRepository = goodsRepository;
_queryService = queryService;
_indexService = indexService;
_indexLock = indexLock;
_logger = logger;
}
@@ -81,6 +92,8 @@ namespace Store.Modules.CloudProduct.Services
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
var mainUser = await _queryService.GetMainUser(userName);
userName = mainUser.UserName;
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
if (goodsIds.Count == 0)
@@ -93,6 +106,7 @@ namespace Store.Modules.CloudProduct.Services
throw new AppException("收藏分组无效", ResultCode.ValidationError);
}
await using var lease = await _indexLock.AcquireFavoritesAsync();
// 1. 校验分组归属(分组ID > 0 时必须是该用户自己创建的分组)
if (request.GroupId > 0)
{
@@ -206,6 +220,28 @@ namespace Store.Modules.CloudProduct.Services
return documents.Count;
}
/// <summary>仅修改安全字段,避免覆盖同时发生的收藏分组变更。</summary>
public async Task SaveProductSafetyAsync(SaveProductSafetyRequest request)
{
RequestValidator.Validate(request);
if (!long.TryParse(request.ProductId, NumberStyles.None, CultureInfo.InvariantCulture, out var goodsId) || goodsId <= 0)
throw new AppException("商品编号必须是正整数", ResultCode.ValidationError);
var userName = request.UserName.Trim();
await using var lease = await _indexLock.AcquireFavoritesAsync();
using var work = _favoriteRepository.Orm.CreateUnitOfWork();
work.GetOrBeginTransaction();
var favorite = await work.Orm.Select<UserGoodsFavoriteEntity>()
.Where(x => x.UserName == userName && x.GoodsId == goodsId).FirstAsync()
?? throw new AppException("该用户尚未收藏此商品", ResultCode.NotFound);
await work.Orm.Update<UserGoodsFavoriteEntity>().Set(x => x.IsSafe, request.IsSafe)
.Where(x => x.Id == favorite.Id).ExecuteAffrowsAsync();
// 重复上报也同步索引,索引明确失败时由工作单元回滚数据库。
await _indexService.UpdateSafetyAsync(UserFavoritesSearchDocument.BuildId(favorite.UserName, goodsId), request.IsSafe!.Value);
work.Commit();
}
/// <summary>
/// 批量取消收藏:先删除数据库记录,再删除索引文档;
/// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。
@@ -229,6 +265,7 @@ namespace Store.Modules.CloudProduct.Services
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
}
await using var lease = await _indexLock.AcquireFavoritesAsync();
var favorites = await _favoriteRepository.Select
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
.ToListAsync();
@@ -404,8 +441,8 @@ namespace Store.Modules.CloudProduct.Services
{
throw new AppException("用户名不能为空", ResultCode.ValidationError);
}
request.UserName = userName;
var mainUser = await _queryService.GetMainUser(userName);
request.UserName = mainUser.UserName;
var searchResult = await _indexService.SearchAsync(request, ParseSortBy(request.SortBy));
var list = searchResult.Documents.Select(ToItem).ToList();
@@ -417,6 +454,7 @@ namespace Store.Modules.CloudProduct.Services
/// </summary>
public async Task<long> RebuildSearchIndexAsync()
{
await using var lease = await _indexLock.AcquireFavoritesAsync();
// 1. 删除并重建索引(清空所有用户收藏索引)
await _indexService.ClearIndexAsync();
@@ -546,6 +584,7 @@ namespace Store.Modules.CloudProduct.Services
{
Id = UserFavoritesSearchDocument.BuildId(favorite.UserName, favorite.GoodsId),
FavId = favorite.Id,
IsSafe = favorite.IsSafe,
UserName = favorite.UserName,
GroupId = favorite.GroupId,
GroupName = groupName,
@@ -568,6 +607,7 @@ namespace Store.Modules.CloudProduct.Services
return new UserFavoriteItem
{
Id = document.FavId,
IsSafe = document.IsSafe,
UserName = document.UserName,
GroupId = document.GroupId,
GroupName = document.GroupName,
@@ -11,8 +11,8 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Modules\Store.Plugins\Store.Plugins.csproj" />
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
<ProjectReference Include="..\..\Core\MAX.Plugins\MAX.Plugins.csproj" />
</ItemGroup>
+12 -1
View File
@@ -6,7 +6,7 @@
"FavoritesIndexName": "user_goods_favorites_search"
},
"RawData": {
"Path": "E:\\GoodsRawData"
"Path": "Z:\\GoodsRawData"
},
"Indexing": {
"Mode": "Background",
@@ -15,5 +15,16 @@
},
"Delete": {
"SoftDelete": true
},
"ImageAnalysisQuota": {
"Url": "http://localhost:9000", //http://119.45.29.75:9000
"APIKey": "e3b0c44298fc1c149afbf4c8996fb924"
},
"ProductInspection": {
"Endpoint": "https://llm-lq2jys2ky6yxo7rh.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
"Model": "qwen3.7-flash",
"ApiKey": "sk-fa7fbaa3bb4047928f30bf05ff52c03a",
"MaxConcurrentAnalysis": 5,
"ConnectionString": "Server=192.168.0.200;Port=3306;Database=product_inspection;Uid=root;Pwd=123456;Allow User Variables=true"
}
}
@@ -13,8 +13,7 @@ using System.Collections.Generic;
namespace KJD_Web.Controllers
{
[ApiVersionNeutral]
[Route("api/ImageCache/[action]")]
[Route("[controller]/[action]")]
public class ImageCacheController : AppControllerBase
{
private readonly ImageCacheService _imageCacheService;
+1 -2
View File
@@ -9,8 +9,7 @@ using Store.Users.Contracts;
namespace Store.Api.Controllers
{
[ApiController]
[ApiVersion("1.0")]
[Route("/v{version:apiVersion}/[controller]/[action]")]
[Route("[controller]/[action]")]
public class AuthController : ControllerBase
{
private readonly IUserAuthenticationService _users;
+1
View File
@@ -0,0 +1 @@
810910819204
+6
View File
@@ -1,7 +1,9 @@
using Asp.Versioning;
using Asp.Versioning.OpenApi;
using Hangfire;
using MAX.Framework.Conventions;
using MAX.Framework.DependencyInjection;
using MAX.Framework.Security;
using Scalar.AspNetCore;
public partial class Program
@@ -80,6 +82,10 @@ public partial class Program
app.UseAuthorization();
app.UseStoreHangfire();
// UTC 19:00 即北京时间次日 03:00;固定任务 ID 避免重启后重复注册。
app.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<TokenService>(
"token-cleanup", service => service.CleanupExpiredTokens(), Cron.Daily(19),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
app.MapControllers();
+10
View File
@@ -8,15 +8,25 @@
"ConnectionStrings": {
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
},
// 登录认证:请求头使用 Authorization: Bearer <accessToken>。
"TokenAuth": {
// 闲置有效期(天):从签发或最近一次续期起计算。
"IdleDays": 7,
// 最长有效期(天):从签发起计算,到期必须重新登录。
"AbsoluteMaxDays": 30,
// 续期间隔(分钟):有效请求间隔达到此值才续期,不能超过最长有效期。
"RenewIntervalMinutes": 10,
// 本机认证缓存(秒):0 不缓存;跨实例撤销最多延迟此时长生效。
"CacheSeconds": 30,
// 有效会话上限:0 不限制;超限踢出最早会话,相同 deviceId 替换旧会话。
"MaxDevicesPerUser": 0,
// 开发固定令牌:仅 Development 环境生效,不受上述限制。
"DevelopmentToken": {
// 是否启用;appsettings.Development.json 中覆盖为 true。
"Enabled": false,
// 调试用 Bearer 令牌,不能为空,区分大小写。
"Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd",
// 调试身份的用户名,不能为空,建议填写已有账号。
"UserName": "18990991234"
}
},
+1
View File
@@ -9,6 +9,7 @@
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
</Folder>
<Folder Name="/Modules/">
<Project Path="Modules/Store.Plugins/Store.Plugins.csproj" Id="b99e7213-b580-4adf-9254-a2be018ccfd3" />
<Project Path="Modules/Store.Shops.Contracts/Store.Shops.Contracts.csproj" Id="7d313823-f86a-450a-be29-8f915fc4b5b3" />
@@ -3,6 +3,7 @@
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.311" />
<ProjectReference Include="../../Plugins/CloudProduct/Store.CloudProduct.csproj" />
</ItemGroup>
</Project>
+78
View File
@@ -56,6 +56,77 @@ Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reason
// SQL generation only: no database connection or production data access.
using var db = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql,
"Server=127.0.0.1;Database=checks;User Id=unused;Password=unused").Build();
// 在内存库验证真实的收藏更新语句和用户隔离,不连接配置中的商品库。
using (var favoriteDb = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.Sqlite, "Data Source=:memory:;Max Pool Size=1").Build())
{
favoriteDb.CodeFirst.SyncStructure<UserGoodsFavoriteEntity>();
favoriteDb.Insert(new[] { "alice", "bob" }.Select(name => new UserGoodsFavoriteEntity
{ UserName = name, GoodsId = 123, CreatedAt = DateTime.Now }).ToArray()).ExecuteAffrows();
var serviceTypeForFavorites = typeof(IUserFavoritesService).Assembly.GetType("Store.Modules.CloudProduct.Services.UserFavoritesService")!;
using var searchContext = new PluginContext("CloudProduct", "Plugins/CloudProduct", new HostingEnvironment());
var indexType = typeof(IUserFavoritesIndexService).Assembly.GetType("Store.Modules.CloudProduct.Services.UserFavoritesIndexService")!;
var index = (IUserFavoritesIndexService)Activator.CreateInstance(indexType, searchContext, null)!;
var indexName = searchContext.Configuration["Search:FavoritesIndexName"]!;
IApiCallDetails? call = null;
void Respond(string body, int status = 200)
{
var settings = new ConnectionSettings(new SingleNodeConnectionPool(new Uri("http://localhost:9200")),
new InMemoryConnection(Encoding.UTF8.GetBytes(body), status)).DefaultIndex(indexName)
.DisableDirectStreaming().OnRequestCompleted(details => call = details);
indexType.GetField("_client", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(index, new OpenSearchClient(settings));
}
Respond("""{"result":"updated","_shards":{"successful":1,"failed":0}}""");
var favorites = (IUserFavoritesService)Activator.CreateInstance(serviceTypeForFavorites,
new Store.Modules.CloudProduct.Repositorys.UserGoodsFavoriteRepository(favoriteDb), null, null, index, null, new LocalFavoritesLock(searchContext))!;
foreach (var safe in new[] { true, false, false })
{
await favorites.SaveProductSafetyAsync(new() { UserName = "alice", ProductId = "123", IsSafe = safe });
using var update = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(update.RootElement.GetProperty("doc").GetProperty("is_safe").GetBoolean() == safe &&
Uri.UnescapeDataString(call.Uri.AbsolutePath).EndsWith("/_update/alice:123") && call.Uri.Query.Contains("refresh=wait_for"),
"Refresh the matching document after a partial safety update");
var rows = favoriteDb.Select<UserGoodsFavoriteEntity>().ToList().ToDictionary(x => x.UserName);
Check(rows["alice"].IsSafe == safe && rows["bob"].IsSafe == null, "Safety updates only the matching favorite");
}
Reject(() => favorites.SaveProductSafetyAsync(new() { UserName = "alice", ProductId = "999", IsSafe = true }).GetAwaiter().GetResult());
Respond("""{"error":{"type":"document_missing_exception","reason":"missing"},"status":404}""", 404);
Reject(() => favorites.SaveProductSafetyAsync(new() { UserName = "alice", ProductId = "123", IsSafe = true }).GetAwaiter().GetResult());
Check(favoriteDb.Select<UserGoodsFavoriteEntity>().Where(x => x.UserName == "alice").First().IsSafe == false, "Roll back on index failure");
Respond("""{"hits":{"total":{"value":12,"relation":"eq"},"hits":[{"_source":{"goods_id":123,"is_safe":false}}]}}""");
var page = await favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = 2, PageIndex = 2, PageSize = 10 });
using var query = JsonDocument.Parse(call!.RequestBodyInBytes);
var filters = query.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter");
Check(filters.EnumerateArray().Any(f => f.GetProperty("term").TryGetProperty("is_safe", out var term) &&
!term.GetProperty("value").GetBoolean()) && query.RootElement.GetProperty("from").GetInt32() == 10,
"Apply safety filter inside paged OpenSearch query");
Check(page.Total == 12 && page.List.Single().IsSafe == false, "Read safety and filtered total from index");
Respond("""{"hits":{"total":{"value":1,"relation":"eq"},"hits":[{"_source":{"goods_id":123}}]}}""");
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var omitted = JsonSerializer.Deserialize<UserFavoritesSearchRequest>("""{"userName":"alice"}""", jsonOptions)!;
await favorites.SearchFavoritesAsync(omitted);
using var allQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(allQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter").GetArrayLength() == 1,
"Omitting status applies only the user filter");
await favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = 0 });
using var zeroQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(zeroQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter").GetArrayLength() == 1, "Status 0 includes all");
await favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = 1 });
using var safeQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
Check(safeQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter").EnumerateArray()
.Any(f => f.GetProperty("term").TryGetProperty("is_safe", out var term) && term.GetProperty("value").GetBoolean()), "Status 1 selects safe");
foreach (int invalid in new[] { -1, 4 })
Reject(() => favorites.SearchFavoritesAsync(new() { UserName = "alice", Status = invalid }).GetAwaiter().GetResult());
var uncheckedRequest = JsonSerializer.Deserialize<UserFavoritesSearchRequest>("""{"userName":"alice","status":3}""", jsonOptions)!;
var uninspected = await favorites.SearchFavoritesAsync(uncheckedRequest);
using var missingQuery = JsonDocument.Parse(call!.RequestBodyInBytes);
var missingFilters = missingQuery.RootElement.GetProperty("query").GetProperty("bool").GetProperty("filter");
Check(missingFilters.GetArrayLength() == 2 && missingFilters.EnumerateArray().Any(f => f.TryGetProperty("bool", out var clause)
&& clause.GetProperty("must_not")[0].GetProperty("exists").GetProperty("field").GetString() == "is_safe"),
"Status 3 selects missing safety values alongside the user filter");
Check(uninspected.List.Single().IsSafe == null, "Missing safety remains null");
Check(favoriteDb.Select<UserGoodsFavoriteEntity>().Count() == 2, "Safety reports never create favorites");
}
var sql = db.Select<GoodsEntity>().As("g")
.Where(x => x.Status == 0 && x.IsSegmented == 0)
.Where("NOT EXISTS (SELECT 1 FROM goods_violation v WHERE v.goods_id = g.goods_id)")
@@ -129,3 +200,10 @@ await TestDelete(200, """{"errors":true,"items":[{"delete":{"_index":"goods","_i
await TestDelete(200, """{"errors":true,"items":[{"delete":{"_index":"goods","_id":"123","status":429,"error":{"type":"rejected","reason":"busy"}}}]}""", true);
await TestDelete(200, """{"errors":false,"items":[]}""", true);
Console.WriteLine($"PASS: {checks} checks (no external services accessed)");
// SQLite 检查独占内存库,无需获取 MySQL 会话锁。
sealed class LocalFavoritesLock(PluginContext context) : GoodsIndexLock(context), IAsyncDisposable
{
public override Task<IAsyncDisposable> AcquireFavoritesAsync() => Task.FromResult<IAsyncDisposable>(this);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../Plugins/CloudProduct/Store.CloudProduct.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
using MAX.Plugins;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Store.Modules.CloudProduct;
using Store.Modules.CloudProduct.Services;
// 本地模拟站点验证服务自行创建的 HttpClient,不调用真实扣费接口。
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0");
await using var app = builder.Build();
var calls = 0;
var status = 200;
app.MapPost("/wxxd_user/UseImageAnalysisQuota", async (HttpContext http) =>
{
calls++;
if (http.Request.Query["token"] != "key+&=?" || http.Request.Query["username"] != "用户 +&=?")
throw new Exception("查询参数编码不符合外部协议。");
http.Response.StatusCode = status;
await http.Response.WriteAsync("{\"success\":true}");
});
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
using var context = new PluginContext("CloudProduct", "Plugins/CloudProduct", builder.Environment);
context.Configuration["ImageAnalysisQuota:APIKey"] = "key+&=?";
var typedContext = new PluginContext<CloudProductPlugin>(context);
// 根地址带或不带末尾斜杠时,都应命中固定接口路径。
foreach (var url in new[] { address, address + "/" })
{
context.Configuration["ImageAnalysisQuota:Url"] = url;
using var service = new ImageAnalysisQuotaService(typedContext);
var body = await service.UseImageAnalysisQuotaAsync(new() { Username = "用户 +&=?" });
if (body != "{\"success\":true}") throw new Exception("应返回原始响应。");
await RejectAsync<ValidationException>(() => service.UseImageAnalysisQuotaAsync(new() { Username = " " }));
context.Configuration["ImageAnalysisQuota:APIKey"] = "";
await RejectAsync<InvalidOperationException>(() => service.UseImageAnalysisQuotaAsync(new() { Username = "test" }));
context.Configuration["ImageAnalysisQuota:APIKey"] = "key+&=?";
}
if (calls != 2) throw new Exception("无效输入或配置不能发送扣减请求。");
context.Configuration["ImageAnalysisQuota:Url"] = "file:///invalid";
await RejectAsync<InvalidOperationException>(() =>
{
using var invalid = new ImageAnalysisQuotaService(typedContext);
return Task.CompletedTask;
});
context.Configuration["ImageAnalysisQuota:Url"] = address;
using (var service = new ImageAnalysisQuotaService(typedContext))
{
status = 500;
await RejectAsync<HttpRequestException>(() => service.UseImageAnalysisQuotaAsync(new() { Username = "用户 +&=?" }));
}
if (calls != 3) throw new Exception("失败请求不能自动重试。");
await app.StopAsync();
Console.WriteLine("Image analysis quota checks passed.");
static async Task RejectAsync<T>(Func<Task> action) where T : Exception
{
try { await action(); }
catch (T) { return; }
throw new Exception($"应抛出 {typeof(T).Name}。");
}
+26 -1
View File
@@ -25,7 +25,7 @@ catch (TargetInvocationException ex) when (mode == "failure" && ex.InnerExceptio
return;
}
if (mode == "failure") throw new Exception("Expected loading failure");
var plugins = assemblies.Where(a => a.GetName().Name is "Store.CloudProduct" or "Store.ImageCache").ToArray();
var plugins = assemblies.Where(a => a.GetName().Name is "Store.CloudProduct" or "Store.ImageCache" or "Store.ProductInspection").ToArray();
if (mode == "empty")
{
if (plugins.Length != 0) throw new Exception("Unexpected plugins");
@@ -43,6 +43,8 @@ if (mode != "image" && !feature.Controllers.Any(t => t.Name == "GoodsController"
if (mode is "image" or "plugins")
{
var host = Host.CreateApplicationBuilder();
host.Services.AddHttpContextAccessor();
host.Services.AddScoped<MAX.Framework.Security.ICurrentUser, MAX.Framework.Security.CurrentUser>();
var core = typeof(ServiceCollectionExtensions).GetMethod("AddStoreCore", BindingFlags.Static | BindingFlags.NonPublic)!;
host.Configuration["Search:Url"] = "https://host.invalid";
host.Configuration["ImageCache:ConnectionString"] = "host-only";
@@ -50,12 +52,35 @@ if (mode is "image" or "plugins")
if (host.Services.Any(d => d.ServiceType == typeof(Hangfire.JobStorage)))
throw new Exception("Plugins must not register shared Hangfire storage");
using var built = host.Build();
foreach (var pluginType in plugins.SelectMany(a => a.GetTypes()).Where(t => !t.IsAbstract && typeof(IPlugin).IsAssignableFrom(t)))
{
var contextType = typeof(PluginContext<>).MakeGenericType(pluginType);
// 先解析泛型入口,确认无需提前解析键名上下文。
var typed = built.Services.GetRequiredService(contextType);
var id = (string)contextType.GetProperty("Id")!.GetValue(typed)!;
var keyed = built.Services.GetRequiredKeyedService<PluginContext>(id);
if (!ReferenceEquals(contextType.GetProperty("Configuration")!.GetValue(typed), keyed.Configuration)
|| !Equals(contextType.GetProperty("DirectoryPath")!.GetValue(typed), keyed.DirectoryPath)
|| !ReferenceEquals(contextType.GetProperty("Environment")!.GetValue(typed), keyed.Environment))
throw new Exception("Typed context must share the plugin configuration and environment");
using var scope = built.Services.CreateScope();
if (!ReferenceEquals(typed, scope.ServiceProvider.GetRequiredService(contextType)))
throw new Exception("Typed plugin context must be singleton");
}
var image = built.Services.GetRequiredKeyedService<PluginContext>("ImageCache");
if (image.Configuration["ImageCache:ConnectionString"] is null or "host-only") throw new Exception("Image config isolation failed");
if (image.Configuration["Search:Url"] != null) throw new Exception("Host settings leaked into ImageCache");
if (host.Configuration["ImageCache:ConnectionString"] != "host-only") throw new Exception("Plugin modified host configuration");
if (mode == "plugins")
{
var inspection = plugins.Single(p => p.GetName().Name == "Store.CloudProduct");
using (var scope = built.Services.CreateScope())
{
scope.ServiceProvider.GetRequiredService(inspection.GetTypes().Single(t => t.Name == "ProductInspectionService"));
scope.ServiceProvider.GetRequiredService(inspection.GetTypes().Single(t => t.Name == "QwenInspectionIntegration"));
}
if (!feature.Controllers.Any(t => t.Name == "ProductInspectionController"))
throw new Exception("Product inspection controller missing");
var cloud = built.Services.GetRequiredKeyedService<PluginContext>("CloudProduct");
if (cloud.Configuration["Search:Url"] is null or "https://host.invalid") throw new Exception("Cloud config isolation failed");
if (host.Configuration["Search:Url"] != "https://host.invalid") throw new Exception("Cloud modified host configuration");
@@ -0,0 +1,141 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using MAX.Framework.Commons;
using MAX.Framework.Security;
using MAX.Plugins;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Store.Modules.CloudProduct.Controllers;
using Store.Modules.CloudProduct.Entities;
using Store.Modules.CloudProduct.Services;
/// <summary>通过真实 HTTP 路由验证图片识别的 DTO 绑定、ApiResult 包装及缓存。</summary>
internal static class HttpChecks
{
public static async Task RunAsync(IFreeSql database)
{
var userName = "http-check-" + Guid.NewGuid().ToString("N");
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0");
using var context = new PluginContext("CloudProduct", "Plugins/CloudProduct", builder.Environment);
context.Configuration["ProductInspection:Model"] = "test-vision";
context.Configuration["ProductInspection:ApiKey"] = "test-key";
builder.Services.AddSingleton(new PluginContext<Store.Modules.CloudProduct.CloudProductPlugin>(context));
builder.Services.AddKeyedSingleton<SemaphoreSlim>("ProductInspectionAnalysisGate", (_, _) => new SemaphoreSlim(5, 5));
builder.Services.AddKeyedSingleton<IFreeSql>("ProductInspectionConnection", database);
builder.Services.AddSingleton<Store.Modules.CloudProduct.Contracts.IInspectionIntegration, QwenInspectionIntegration>();
builder.Services.AddScoped<ProductInspectionService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddControllers(options =>
{
options.Conventions.Add(new MAX.Framework.Conventions.ApiRoutePrefixConvention());
options.Filters.Add<UnifyResultFilter>();
options.Filters.Add<GlobalExceptionFilter>();
}).AddApplicationPart(typeof(ProductInspectionController).Assembly);
await using var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapPost("/test/chat", RecognitionEndpoint.RespondAsync);
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
context.Configuration["ProductInspection:Endpoint"] = address + "/test/chat";
using var client = new HttpClient { BaseAddress = new Uri(address + "/api/ProductInspection/") };
var checks = 0;
void Check(bool condition, string message)
{
if (!condition) throw new Exception(message);
checks++;
}
// 随机化图片内容及登录身份,清理时只删除本次检查创建的行。
var data = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jZQAAAABJRU5ErkJggg==")
.Concat(Guid.NewGuid().ToByteArray()).ToArray();
var image = Convert.ToBase64String(data);
var key = Guid.NewGuid().ToString("N");
async Task<JsonElement> Inspect(string image, string? md5)
{
using var response = await client.PostAsJsonAsync("InspectImage", new { userName, md5, image });
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc.RootElement.Clone();
}
try
{
client.DefaultRequestHeaders.Add("X-Test-User", userName);
var first = await Inspect(image, key.ToUpperInvariant());
Check(first.TryGetProperty("code", out var firstCode) && firstCode.GetInt32() == 200, first.ToString());
string[] expected = ["急速发货", "螺旋伸缩", "金属挂钩", "可调节", "不挡扇叶", "倒挂钩卡口"];
Check(first.GetProperty("data").Deserialize<string[]>()!.SequenceEqual(expected), "Ordered recognition array");
Check((await Inspect(image, key)).GetProperty("data").Deserialize<string[]>()!.SequenceEqual(expected), "Cache hit preserves item order");
var stored = await database.Select<ImageAnalysisCache>().Where(x => x.CacheKey == key).FirstAsync();
Check(stored.RecognitionResult == """["急速发货","螺旋伸缩","金属挂钩","可调节","不挡扇叶","倒挂钩卡口"]""", "Cache stores distinct items with readable Chinese");
await database.Update<ImageAnalysisCache>().Set(x => x.RecognitionResult, "[]").Where(x => x.CacheKey == key).ExecuteAffrowsAsync();
var otherImage = Convert.ToBase64String(data.Append((byte)42).ToArray());
var hit = (await Inspect(otherImage, key)).GetProperty("data");
Check(hit.GetArrayLength() == 0, "Same client key reuses cached result for different image bytes");
await database.Update<ImageAnalysisCache>().Set(x => x.RecognitionResult, """{"text":"legacy","logoBrands":[]}""")
.Where(x => x.CacheKey == key).ExecuteAffrowsAsync();
Check((await Inspect(image, key)).GetProperty("data").Deserialize<string[]>()!.SequenceEqual(expected), "Refresh legacy cache");
Check((await Inspect(Convert.ToBase64String(new byte[30]), key)).GetProperty("code").GetInt32() != 200, "Invalid image denied");
Check((await Inspect("invalid!", key)).GetProperty("code").GetInt32() != 200, "Malformed Base64 denied before cache hit");
foreach (var invalid in new[] { "invalid", new string('z', 32), null })
{
var rejected = await Inspect(otherImage, invalid);
Check(!rejected.TryGetProperty("code", out var code) || code.GetInt32() != 200, "Reject missing or malformed MD5 before cache hit");
}
Console.WriteLine($"PASS {checks} HTTP and MySQL checks");
}
finally
{
await app.StopAsync();
await database.Delete<ImageAnalysisCache>().Where(x => x.CacheKey == key).ExecuteAffrowsAsync();
}
}
}
/// <summary>仅测试宿主使用的身份处理器,生产插件始终沿用宿主 Bearer 认证。</summary>
internal sealed class TestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var name = Request.Headers["X-Test-User"].ToString();
if (string.IsNullOrEmpty(name)) return Task.FromResult(AuthenticateResult.NoResult());
var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.UserName, name)], Scheme.Name));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
}
}
/// <summary>返回模型协议示例,使 HTTP 检查无需外部密钥。</summary>
internal static class RecognitionEndpoint
{
private const string Content = """["\u6025\u901F\u53D1\u8D27","急速发货","螺旋伸缩","急速发货","金属挂钩","可调节","不挡扇叶","倒挂钩卡口"]""";
internal static async Task<IResult> RespondAsync(HttpRequest request, CancellationToken cancellationToken)
{
using var body = await JsonDocument.ParseAsync(request.Body, cancellationToken: cancellationToken);
var root = body.RootElement;
var parts = root.GetProperty("messages")[0].GetProperty("content");
if (root.GetProperty("model").GetString() != "test-vision"
|| request.Headers.Authorization.ToString() != "Bearer test-key"
|| root.GetProperty("stream").GetBoolean() || root.GetProperty("enable_thinking").GetBoolean()
|| !parts[0].GetProperty("image_url").GetProperty("url").GetString()!.StartsWith("data:image/png;base64,iVBOR")
|| !parts[1].GetProperty("text").GetString()!.Contains("JSON 字符串数组"))
throw new Exception("Invalid model request");
return Results.Json(new { choices = new[] { new { message = new { content = Content } } } });
}
}
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><Nullable>enable</Nullable></PropertyGroup>
<ItemGroup><ProjectReference Include="../../Plugins/CloudProduct/Store.CloudProduct.csproj" /></ItemGroup>
</Project>
+39
View File
@@ -0,0 +1,39 @@
using System.Text.Json;
using Store.Modules.CloudProduct.Entities;
using var config = JsonDocument.Parse(File.ReadAllText("Plugins/CloudProduct/appsettings.json"));
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"))
{
// 旧表只移除两个派生字段,保留缓存键、响应原文及更新时间。
var existing = await db.Ado.QueryAsync<string>(tableQuery);
if (!existing.Contains("image_analysis_cache")) db.CodeFirst.SyncStructure(typeof(ImageAnalysisCache));
var columns = await db.Ado.QueryAsync<string>(columnsQuery);
var oldResult = new[] { "FormattedData", "识别结果" }.SingleOrDefault(columns.Contains);
if (oldResult != null)
await db.Ado.ExecuteNonQueryAsync($"ALTER TABLE image_analysis_cache CHANGE COLUMN `{oldResult}` RecognitionResult LONGTEXT NOT NULL");
var drops = new[] { "DetectionText", "LogoBrands" }.Where(columns.Contains).Select(name => $"DROP COLUMN `{name}`").ToArray();
if (drops.Length > 0) await db.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache " + string.Join(", ", drops));
// 旧键不能还原为图片 MD5,禁止在有旧缓存的环境中直接缩短主键。
if (await db.Select<ImageAnalysisCache>().Where(x => x.CacheKey.Length > 32).AnyAsync())
throw new InvalidOperationException("请先归档或清理旧的 SHA-256 图片缓存,再执行升级。");
db.CodeFirst.SyncStructure(typeof(ImageAnalysisCache));
await db.Ado.ExecuteNonQueryAsync("DROP TABLE IF EXISTS inspection_task_item, inspection_task, inspection_result, inspection_user_rule");
}
var tables = await db.Ado.QueryAsync<string>(tableQuery);
Console.WriteLine(string.Join(", ", tables));
var cacheColumns = await db.Ado.QueryAsync<string>(columnsQuery);
Console.WriteLine("Cache columns: " + string.Join(", ", cacheColumns));
Console.WriteLine($"Cache rows: {await db.Select<ImageAnalysisCache>().CountAsync()}");
if (args.Contains("--schema")) return;
if (!cacheColumns.ToHashSet().SetEquals(new[] { "CacheKey", "RecognitionResult", "UpdatedAt" }))
throw new Exception("图片识别缓存结构不匹配。");
if (!tables.Contains("image_analysis_cache"))
throw new Exception("商品检测专用库缺少图片缓存表。");
var keyLength = await db.Ado.ExecuteScalarAsync("SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' AND COLUMN_NAME = 'CacheKey'");
if (Convert.ToInt32(keyLength) != 32) throw new Exception("CacheKey 应为 32 位 MD5。");
await HttpChecks.RunAsync(db);
@@ -145,6 +145,35 @@ public sealed class PersistentSessionTests
Assert.NotNull(await service.ValidateTokenAsync(replacement.Token));
}
[Fact]
public async Task Cleanup_RemovesRevokedAndExpiredTokens_AndPreservesActiveTokens()
{
using var db = Open(":memory:");
using var cache = new MemoryCache(new MemoryCacheOptions());
var clock = new Clock();
var service = new TokenService(db, Options.Create(new TokenAuthOptions()), cache, clock);
var active = Issue(service);
Assert.NotNull(await service.ValidateTokenAsync(active.Token));
var now = DateTime.SpecifyKind(clock.Now.UtcDateTime, DateTimeKind.Unspecified);
// 覆盖撤销、闲置到期、绝对到期的等值边界,以及尚差一秒到期的有效记录。
db.Insert(new[]
{
new UserToken { UserName = "same", TokenHash = "revoked", CreatedAt = now, ExpiresAt = now.AddDays(7), Revoked = true },
new UserToken { UserName = "same", TokenHash = "idle", CreatedAt = now.AddDays(-7), ExpiresAt = now },
new UserToken { UserName = "same", TokenHash = "absolute", CreatedAt = now.AddDays(-30), ExpiresAt = now.AddDays(1) },
new UserToken { UserName = "same", TokenHash = "near-expiry", CreatedAt = now.AddDays(-30).AddSeconds(1), ExpiresAt = now.AddSeconds(1) }
}).ExecuteAffrows();
Assert.Equal(3, service.CleanupExpiredTokens());
Assert.Equal(2, db.Select<UserToken>().Count());
Assert.True(db.Select<UserToken>().Any(t => t.TokenHash == "near-expiry"));
Assert.Equal(0, service.CleanupExpiredTokens());
Assert.NotNull(await service.ValidateTokenAsync(active.Token));
clock.Now = clock.Now.AddSeconds(1);
Assert.Equal(1, service.CleanupExpiredTokens());
Assert.Single(db.Select<UserToken>().ToList());
}
[Fact]
public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry()
{
@@ -182,9 +182,9 @@ public sealed class UserManagementTests
.Set(u => u.SyncLogisticsPerOrder, 0.5m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
var info = await context.Query.GetUser("child");
Assert.Equal("brand", info.OemId);
Assert.Equal(2.2m, info.PostagePerOrder);
Assert.Equal(2.5m, info.RefundPostagePerOrder);
Assert.Equal(0m, info.GiftOemAmountPerOrder);
Assert.Equal(1m, info.PostagePerOrder);
Assert.Equal(1m, info.RefundPostagePerOrder);
Assert.Equal(-1m, info.GiftOemAmountPerOrder);
Assert.Equal(0.5m, info.SyncLogisticsPerOrder);
await context.Db.Update<UserEntity>().Set(u => u.Authority, "订单").Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
Assert.DoesNotContain("积分用户", (await context.Query.GetUser("child")).Authority);
@@ -0,0 +1,61 @@
using MAX.Framework.Commons;
using Store.Users.Entities;
using Store.Users.Repositories;
using Store.Users.Services;
namespace Store.Users.Tests;
/// <summary>验证主账号解析及无效父账号的继承边界。</summary>
public sealed class UserRepositoryTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ChildFees_MatchMainAccount_IncludingUnsetValues(bool unset)
{
using var context = new UserTestContext();
decimal? fee = unset ? null : 0.5m;
await context.Db.Update<UserEntity>().Set(u => u.PostagePerOrder, fee)
.Set(u => u.RefundPostagePerOrder, fee).Set(u => u.GiftOemAmountPerOrder, fee)
.Set(u => u.SyncLogisticsPerOrder, fee).Set(u => u.ShopPointsPerMonth, 45)
.Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
var main = await context.Query.GetUser("main");
var child = await context.Query.GetUser("child");
Assert.Equal(main.PostagePerOrder, child.PostagePerOrder);
Assert.Equal(main.RefundPostagePerOrder, child.RefundPostagePerOrder);
Assert.Equal(main.GiftOemAmountPerOrder, child.GiftOemAmountPerOrder);
Assert.Equal(main.SyncLogisticsPerOrder, child.SyncLogisticsPerOrder);
Assert.Equal(45, child.ShopPointsPerMonth);
Assert.Equal(30, (await context.Read("child")).ShopPointsPerMonth);
}
[Theory]
[InlineData("main", "main")]
[InlineData("child", "main")]
[InlineData("missing", null)]
public async Task GetMainUser_ResolvesOwner(string userName, string? expected)
{
using var context = new UserTestContext();
var repository = (IUserRepository)Activator.CreateInstance(
typeof(UserManagementService).Assembly.GetType("Store.Users.Repositories.UserRepository", true)!, context.Db)!;
Assert.Equal(expected, (await repository.GetMainUser(userName))?.UserName);
}
[Theory]
[InlineData("missing")]
[InlineData("child")]
[InlineData("")]
public async Task InvalidParent_IsRejectedByBothQueries(string parent)
{
using var context = new UserTestContext();
await context.Db.Update<UserEntity>().Set(u => u.ParentUserName, parent)
.Where(u => u.Id == context.Child.Id).ExecuteAffrowsAsync();
var repository = (IUserRepository)Activator.CreateInstance(
typeof(UserManagementService).Assembly.GetType("Store.Users.Repositories.UserRepository", true)!, context.Db)!;
await Assert.ThrowsAsync<AppException>(() => repository.GetUser("child"));
await Assert.ThrowsAsync<AppException>(() => repository.GetMainUser("child"));
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ try
actor.UserName = main.UserName;
var sub = await users.Create(new() { UserName = "child", Password = "child-password", Authority = "积分用户" }, true, default);
Assert(sub.ParentUserName == main.UserName && sub.OemId == main.OemId, "子账号归属或OEM继承错误");
Assert(sub.PostagePerOrder == 2.2m && sub.RefundPostagePerOrder == 2.5m, "继承费用下限错误");
Assert(sub.PostagePerOrder == main.PostagePerOrder && sub.RefundPostagePerOrder == main.RefundPostagePerOrder, "子账号费用应与主账号一致");
await Reject(() => users.Create(new() { UserName = "bad", Password = "password", Authority = "超级权限" }, true, default));
await Reject(() => users.Create(new() { UserName = "bad-admin", Password = "password", SaLevel = 1 }, true, default));
await Reject(() => users.Update(sub.Id, new() { ShopPointsPerMonth = 30 }, true, default));