diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs index 4bb0c09..2fa9d59 100644 --- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs @@ -260,6 +260,10 @@ namespace MAX.Framework.DependencyInjection provider.GetRequiredService(); return context; }); + var typedContext = typeof(PluginContext<>).MakeGenericType(plugin.GetType()); + host.Services.AddSingleton(typedContext, provider => + // 复用键名注册,确保仅解析泛型上下文时也由容器管理配置的释放。 + Activator.CreateInstance(typedContext, provider.GetRequiredKeyedService(plugin.Id))!); RunDebugStep("Plugin", $"注册 {plugin.Id} ({plugin.GetType().FullName}),Priority={plugin.Priority}", () => plugin.ConfigureServices(host.Services, context)); } diff --git a/Core/MAX.Framework/Security/README.md b/Core/MAX.Framework/Security/README.md index 938f671..4601451 100644 --- a/Core/MAX.Framework/Security/README.md +++ b/Core/MAX.Framework/Security/README.md @@ -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 字段,因此未假设这些字段或新增角色策略。 diff --git a/Core/MAX.Framework/Security/TokenService.cs b/Core/MAX.Framework/Security/TokenService.cs index 9a9c1fb..b4a92fa 100644 --- a/Core/MAX.Framework/Security/TokenService.cs +++ b/Core/MAX.Framework/Security/TokenService.cs @@ -131,6 +131,20 @@ public sealed class TokenService(IFreeSql db, IOptions options } } + /// 清理已撤销或已到期的会话,返回删除数量,供每日后台任务调用。 + public int CleanupExpiredTokens() + { + lock (Gate) + { + var now = Now; + var cutoff = now.AddDays(-settings.AbsoluteMaxDays); + // 与认证的到期边界一致;直接条件删除,避免先查询再删除时误删已续期的会话。 + return db.Delete() + .Where(t => t.Revoked || t.ExpiresAt <= now || t.CreatedAt <= cutoff) + .ExecuteAffrows(); + } + } + internal void Revoke(string userName, string? sessionId = null) { lock (Gate) diff --git a/Core/MAX.Plugins/PluginContext.cs b/Core/MAX.Plugins/PluginContext.cs index 52e9b88..162d0d7 100644 --- a/Core/MAX.Plugins/PluginContext.cs +++ b/Core/MAX.Plugins/PluginContext.cs @@ -37,3 +37,20 @@ public sealed class PluginContext : IDisposable (Configuration as IDisposable)?.Dispose(); } } + + +/// 按插件类型注入的上下文视图,配置及其生命周期由宿主的 PluginContext 统一管理。 +public sealed class PluginContext(PluginContext context) where TPlugin : IPlugin +{ + /// 插件唯一标识。 + public string Id => context.Id; + /// 插件程序集和配置所在目录。 + public string DirectoryPath => context.DirectoryPath; + /// 当前宿主环境。 + public IHostEnvironment Environment => context.Environment; + /// 与插件入口共享的独立配置实例。 + public IConfigurationRoot Configuration => context.Configuration; + + /// 读取必填配置,缺失时沿用插件上下文的错误说明。 + public string RequireConfiguration(string key) => context.RequireConfiguration(key); +} diff --git a/Modules/Store.Plugins/Store.Plugins.csproj b/Modules/Store.Plugins/Store.Plugins.csproj new file mode 100644 index 0000000..463e3bf --- /dev/null +++ b/Modules/Store.Plugins/Store.Plugins.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Modules/Store.Users.Contracts/IUserQueryService.cs b/Modules/Store.Users.Contracts/IUserQueryService.cs index f6808da..8ff85e1 100644 --- a/Modules/Store.Users.Contracts/IUserQueryService.cs +++ b/Modules/Store.Users.Contracts/IUserQueryService.cs @@ -7,6 +7,7 @@ namespace Store.Users.Contracts { public interface IUserQueryService { + Task GetMainUser(string userName); Task GetUser(string userName); } } diff --git a/Modules/Store.Users/Controllers/UserController.cs b/Modules/Store.Users/Controllers/UserController.cs index 73c4208..82a9e88 100644 --- a/Modules/Store.Users/Controllers/UserController.cs +++ b/Modules/Store.Users/Controllers/UserController.cs @@ -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; /// 用户模块接口;所有管理操作均以当前登录身份对应的数据库账户进行授权。 -[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 { /// 获取本人或授权范围内的用户信息,响应不包含密码。 [EndpointSummary("获取用户信息")] diff --git a/Modules/Store.Users/Repositories/UserRepository.cs b/Modules/Store.Users/Repositories/UserRepository.cs index dd27ce9..77b1cfd 100644 --- a/Modules/Store.Users/Repositories/UserRepository.cs +++ b/Modules/Store.Users/Repositories/UserRepository.cs @@ -5,22 +5,28 @@ namespace Store.Users.Repositories; public interface IUserRepository : IBaseRepository { + /// 获取用户及从主账号继承的有效配置;用户不存在时返回空。 Task GetUser(string userName); + + /// 获取用户所属主账号(主账号返回自身);用户不存在时返回空,主账号无效时抛出业务异常。 + Task GetMainUser(string userName); } /// 集中处理旧系统主子账号的有效计费配置和积分权限继承。 internal class UserRepository(IFreeSql freeSql) : BaseRepository(freeSql), IUserRepository, ITransientDependency { + /// public async Task 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(fre user.Authority = string.Join(",", authority); return user; } + /// + public async Task GetMainUser(string userName) + { + var user = await Where(u => u.UserName == userName).FirstAsync(); + return user == null ? null : await GetMainUser(user); + } + + /// 统一校验主子账号关系,禁止从其他子账号继承配置。 + private async Task 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; + } } diff --git a/Modules/Store.Users/Services/UserQueryService.cs b/Modules/Store.Users/Services/UserQueryService.cs index da1f219..36d8329 100644 --- a/Modules/Store.Users/Services/UserQueryService.cs +++ b/Modules/Store.Users/Services/UserQueryService.cs @@ -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); } + /// + /// 获取主账号 + /// + /// + /// + public async Task 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) diff --git a/Plugins/CloudProduct/CloudProductPlugin.cs b/Plugins/CloudProduct/CloudProductPlugin.cs index 35d3f7c..70b0be2 100644 --- a/Plugins/CloudProduct/CloudProductPlugin.cs +++ b/Plugins/CloudProduct/CloudProductPlugin.cs @@ -33,6 +33,17 @@ namespace Store.Modules.CloudProduct new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connectionStringBuilder.ConnectionString) .UseAdoConnectionPool(true).Build()); services.AddHostedService(); + // 服务自行创建客户端,单例复用连接池并由容器负责释放。 + services.AddSingleton(); + + // 图片识别沿用专用缓存库,所有请求共享并发上限。 + var inspectionConnection = RequireConfiguration("ProductInspection:ConnectionString"); + services.AddKeyedSingleton("ProductInspectionConnection", (_, _) => + new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, inspectionConnection) + .UseAdoConnectionPool(true).Build()); + var maximum = Context.Configuration.GetValue("ProductInspection:MaxConcurrentAnalysis") ?? 5; + if (maximum is < 1 or > 100) throw new InvalidOperationException("识别并发数必须在 1 至 100 之间。"); + services.AddKeyedSingleton("ProductInspectionAnalysisGate", (_, _) => new SemaphoreSlim(maximum, maximum)); } diff --git a/Plugins/CloudProduct/Contracts/IInspectionIntegration.cs b/Plugins/CloudProduct/Contracts/IInspectionIntegration.cs new file mode 100644 index 0000000..dc26c67 --- /dev/null +++ b/Plugins/CloudProduct/Contracts/IInspectionIntegration.cs @@ -0,0 +1,7 @@ +namespace Store.Modules.CloudProduct.Contracts; + +/// 按图片阅读顺序返回文字和中文品牌名,无识别项返回空数组。 +public interface IInspectionIntegration +{ + Task AnalyzeAsync(Stream image, string mediaType, CancellationToken cancellationToken); +} diff --git a/Plugins/CloudProduct/Controllers/GoodsController.cs b/Plugins/CloudProduct/Controllers/GoodsController.cs index 92a62d7..c3442fa 100644 --- a/Plugins/CloudProduct/Controllers/GoodsController.cs +++ b/Plugins/CloudProduct/Controllers/GoodsController.cs @@ -15,8 +15,7 @@ namespace Store.Modules.CloudProduct.Controllers { /// 云商品管理:商品查询、导入、违规上报及搜索索引维护。 [Tags("云商品")] - [ApiVersionNeutral] - [Route("api/CloudGoods/[action]")] + [Route("CloudGoods/[action]")] public class GoodsController : AppControllerBase { private readonly IGoodsService _goodsService; diff --git a/Plugins/CloudProduct/Controllers/ProductInspectionController.cs b/Plugins/CloudProduct/Controllers/ProductInspectionController.cs new file mode 100644 index 0000000..94f4955 --- /dev/null +++ b/Plugins/CloudProduct/Controllers/ProductInspectionController.cs @@ -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; + +/// 上传图片识别及当前用户的商品安全记录。 +[Tags("商品检测"), DefaultHttpPost] +[Route("api/CloudGoods/[action]")] +public sealed class ProductInspectionController(ProductInspectionService service) : AppControllerBase +{ + [EndpointSummary("识别上传图片中的文字和品牌 Logo")] + [RequestFormLimits(ValueLengthLimit = ProductInspectionService.MaxBase64Length)] + public Task InspectImage(InspectImageRequest request, CancellationToken cancellationToken) + => service.InspectAsync(request, cancellationToken); +} diff --git a/Plugins/CloudProduct/Controllers/UserFavoritesController.cs b/Plugins/CloudProduct/Controllers/UserFavoritesController.cs index 5023a8e..248ce32 100644 --- a/Plugins/CloudProduct/Controllers/UserFavoritesController.cs +++ b/Plugins/CloudProduct/Controllers/UserFavoritesController.cs @@ -58,6 +58,14 @@ namespace Store.Modules.CloudProduct.Controllers return await _favoritesService.AddFavoritesAsync(request); } + /// 更新用户已收藏商品的安全结论。 + [HttpPost] + public async Task SaveProductSafety(SaveProductSafetyRequest request) + { + await _favoritesService.SaveProductSafetyAsync(request); + return ApiResult.Ok(); + } + /// /// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。 /// diff --git a/Plugins/CloudProduct/Dtos/InspectionDtos.cs b/Plugins/CloudProduct/Dtos/InspectionDtos.cs new file mode 100644 index 0000000..00451f5 --- /dev/null +++ b/Plugins/CloudProduct/Dtos/InspectionDtos.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Store.Modules.CloudProduct.Dtos; + +/// 单张商品图片,使用 multipart/form-data 上传。 +public sealed class InspectImageRequest +{ + [Required] public string UserName { get; set; } = ""; + /// 客户端指定的 32 位十六进制 MD5 缓存键,大小写均可。 + [Required] public string Md5 { get; set; } = ""; + /// 图片原始字节的 Base64 字符串,不含 Data URL 前缀。 + [Required] public string Image { get; set; } = ""; +} + +///// 在当前登录用户和指定平台内按商品 ID 批量查询。 +//public sealed class QueryProductSafetyRequest +//{ +// [Required] public string UserName { get; set; } = ""; +// [Required] public string[] ProductIds { get; set; } = []; +//} + +//public sealed record ProductSafetyDto(string ProductId, bool IsSafe); diff --git a/Plugins/CloudProduct/Dtos/SaveProductSafetyRequest.cs b/Plugins/CloudProduct/Dtos/SaveProductSafetyRequest.cs new file mode 100644 index 0000000..e44f68a --- /dev/null +++ b/Plugins/CloudProduct/Dtos/SaveProductSafetyRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Store.Modules.CloudProduct.Dtos; + +/// 保存收藏商品的检测结论,沿用原接口的字符串商品编号。 +public sealed class SaveProductSafetyRequest +{ + [Required] public string UserName { get; set; } = ""; + [Required] public string ProductId { get; set; } = ""; + [Required] public bool? IsSafe { get; set; } +} diff --git a/Plugins/CloudProduct/Dtos/UseImageAnalysisQuotaRequest.cs b/Plugins/CloudProduct/Dtos/UseImageAnalysisQuotaRequest.cs new file mode 100644 index 0000000..b6382ce --- /dev/null +++ b/Plugins/CloudProduct/Dtos/UseImageAnalysisQuotaRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Store.Modules.CloudProduct.Dtos; + +/// 外部网站图片识别额度扣减请求。 +public sealed class UseImageAnalysisQuotaRequest +{ + /// 需要使用识别额度的外部网站用户名。 + [Required(ErrorMessage = "用户名不能为空")] + public string UserName { get; set; } = string.Empty; +} diff --git a/Plugins/CloudProduct/Dtos/UserFavoriteItem.cs b/Plugins/CloudProduct/Dtos/UserFavoriteItem.cs index 4778393..298290b 100644 --- a/Plugins/CloudProduct/Dtos/UserFavoriteItem.cs +++ b/Plugins/CloudProduct/Dtos/UserFavoriteItem.cs @@ -49,5 +49,9 @@ namespace Store.Modules.CloudProduct.Dtos /// 收藏时间 public DateTime CreatedAt { get; set; } + /// + /// 安全检测 + /// + public bool? IsSafe { get; set; } } } \ No newline at end of file diff --git a/Plugins/CloudProduct/Dtos/UserFavoritesSearchDocument.cs b/Plugins/CloudProduct/Dtos/UserFavoritesSearchDocument.cs index 096623c..bf6fb70 100644 --- a/Plugins/CloudProduct/Dtos/UserFavoritesSearchDocument.cs +++ b/Plugins/CloudProduct/Dtos/UserFavoritesSearchDocument.cs @@ -9,6 +9,10 @@ namespace Store.Modules.CloudProduct.Dtos /// public class UserFavoritesSearchDocument { + /// 安全检测结论;为空表示尚未检测。 + [Boolean(Name = "is_safe")] + public bool? IsSafe { get; set; } + /// /// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id} /// diff --git a/Plugins/CloudProduct/Dtos/UserFavoritesSearchRequest.cs b/Plugins/CloudProduct/Dtos/UserFavoritesSearchRequest.cs index baf9ec6..fff8af6 100644 --- a/Plugins/CloudProduct/Dtos/UserFavoritesSearchRequest.cs +++ b/Plugins/CloudProduct/Dtos/UserFavoritesSearchRequest.cs @@ -5,6 +5,9 @@ namespace Store.Modules.CloudProduct.Dtos /// public class UserFavoritesSearchRequest { + /// 安全状态:0 全部(默认),1 安全,2 违规,3 未检测。 + public int SafeStatus { get; set; } + /// /// 用户名(为空时取当前登录用户) /// diff --git a/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs b/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs new file mode 100644 index 0000000..4c24625 --- /dev/null +++ b/Plugins/CloudProduct/Entities/ImageAnalysisCache.cs @@ -0,0 +1,17 @@ +using FreeSql.DataAnnotations; + +namespace Store.Modules.CloudProduct.Entities; + +/// 跨用户复用的图片识别数据,仅存储成功识别结果。 +[Table(Name = "image_analysis_cache")] +public sealed class ImageAnalysisCache +{ + /// 客户端指定的 MD5 缓存键,统一保存为 32 位小写十六进制。 + [Column(IsPrimary = true, StringLength = 32)] + public string CacheKey { get; set; } = ""; + /// 模型识别结果的 JSON 字符串数组,空数组表示未识别到文字或品牌。 + [Column(DbType = "longtext")] + public string RecognitionResult { get; set; } = ""; + /// 缓存最近更新时间,UTC。 + public DateTime UpdatedAt { get; set; } +} diff --git a/Plugins/CloudProduct/Entities/UserGoodsFavoriteEntity.cs b/Plugins/CloudProduct/Entities/UserGoodsFavoriteEntity.cs index d571df8..d1dd6d4 100644 --- a/Plugins/CloudProduct/Entities/UserGoodsFavoriteEntity.cs +++ b/Plugins/CloudProduct/Entities/UserGoodsFavoriteEntity.cs @@ -34,6 +34,10 @@ namespace Store.Modules.CloudProduct.Entities [Column(Name = "goods_id", IsNullable = false)] public long GoodsId { get; set; } + /// 最近上报的安全结论,null 表示尚未检测。 + [Column(Name = "is_safe", IsNullable = true)] + public bool? IsSafe { get; set; } + /// /// 收藏时间 /// diff --git a/Plugins/CloudProduct/ProductInspection.md b/Plugins/CloudProduct/ProductInspection.md new file mode 100644 index 0000000..ffb2d5d --- /dev/null +++ b/Plugins/CloudProduct/ProductInspection.md @@ -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 键的缓存。 diff --git a/Plugins/CloudProduct/Services/GoodsIndexLock.cs b/Plugins/CloudProduct/Services/GoodsIndexLock.cs index bc5cda8..ffbe858 100644 --- a/Plugins/CloudProduct/Services/GoodsIndexLock.cs +++ b/Plugins/CloudProduct/Services/GoodsIndexLock.cs @@ -11,7 +11,12 @@ namespace Store.Modules.CloudProduct.Services; /// 跨实例串行化违规上报和商品索引写入,防止下架后被旧批次写回。 public class GoodsIndexLock([FromKeyedServices("CloudProduct")] PluginContext context) : ISingletonDependency { - public async Task AcquireAsync() + public Task AcquireAsync() => AcquireAsync("goods-index:"); + + /// 收藏重建、整条文档写入和安全状态更新共用独立锁,防止旧快照覆盖新结论。 + public virtual Task AcquireFavoritesAsync() => AcquireAsync("favorites-index:"); + + private async Task 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 { diff --git a/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs b/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs new file mode 100644 index 0000000..d80657a --- /dev/null +++ b/Plugins/CloudProduct/Services/ImageAnalysisQuotaService.cs @@ -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; + +/// 调用外部网站的图片识别额度接口,单例复用 HTTP 连接。 +public sealed class ImageAnalysisQuotaService : IDisposable +{ + private readonly HttpClient client; + private readonly PluginContext context; + + /// 从站点根地址创建客户端,定期更新连接以跟随 DNS 变化。 + public ImageAnalysisQuotaService(PluginContext 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 }; + } + + /// 随服务释放客户端及连接池。 + public void Dispose() => client.Dispose(); + + /// + /// 使用指定用户的识别额度,返回外部接口原始响应;HTTP 失败直接抛出异常。 + /// 额度扣减不自动重试,避免请求已执行但响应丢失时重复扣减。 + /// + public async Task UseImageAnalysisQuotaAsync(UseImageAnalysisQuotaRequest request, + CancellationToken cancellationToken = default) + { + RequestValidator.Validate(request); + // 外部协议要求 token 和 username 放在查询字符串中,统一编码特殊字符。 + var requestUrl = QueryHelpers.AddQueryString("/wxxd_user/UseImageAnalysisQuota", new Dictionary + { + ["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(html); + if (result != null && result.Success) + { + return true; + } + throw new AppException(result.Msg); + + } +} diff --git a/Plugins/CloudProduct/Services/ProductInspectionService.cs b/Plugins/CloudProduct/Services/ProductInspectionService.cs new file mode 100644 index 0000000..c8726f2 --- /dev/null +++ b/Plugins/CloudProduct/Services/ProductInspectionService.cs @@ -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; + +/// 商品检测业务入口,负责上传图片识别、MD5 缓存和当前用户的商品安全记录。 +public sealed class ProductInspectionService(IInspectionIntegration integration, ICurrentUser user, ImageAnalysisQuotaService imageAnalysisQuotaService, + [FromKeyedServices("CloudProductConnection")] IFreeSql database, + [FromKeyedServices("ProductInspectionAnalysisGate")] SemaphoreSlim gate, + ILogger logger + ) : IScopedDependency +{ + public const int MaxBytes = 10 * 1024 * 1024; + + // 缓存 JSON 保留中文原文,字符串中的引号和控制字符仍由序列化器转义。 + private static readonly JsonSerializerOptions CacheJsonOptions = new() + { + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) + }; + + // 每三个图片字节编码为四个 Base64 字符,末组向上取整。 + public const int MaxBase64Length = (MaxBytes + 2) / 3 * 4; + + public async Task InspectAsync(InspectImageRequest request, CancellationToken cancellationToken) + { + RequestValidator.Validate(request); + if (request.Md5.Length != 32 || !request.Md5.All(char.IsAsciiHexDigit)) + throw new ValidationException("md5 必须是 32 位十六进制缓存键。"); + var md5 = request.Md5.ToLowerInvariant(); + if (request.Image.Length > MaxBase64Length) + throw new ValidationException("图片超过 10 MiB 或 Base64 文本过长。"); + + byte[] image; + try { image = Convert.FromBase64String(request.Image); } + catch (FormatException) { throw new ValidationException("image 必须是有效的 Base64 字符串。"); } + if (image.Length is < 12 or > MaxBytes) + throw new ValidationException("图片为空、格式无效或超过 10 MiB。"); + // 依据文件签名识别允许的图片类型,避免信任客户端文件名和 MIME 类型。 + string mediaType; + if (image[0] == 0xff && image[1] == 0xd8 && image[2] == 0xff) mediaType = "image/jpeg"; + else if (image.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 })) mediaType = "image/png"; + else if (image.AsSpan(0, 4).SequenceEqual("RIFF"u8) && image.AsSpan(8, 4).SequenceEqual("WEBP"u8)) mediaType = "image/webp"; + else throw new ValidationException("请上传 JPEG、PNG 或 WebP 图片。"); + var cached = await database.Select().Where(x => x.CacheKey == md5).FirstAsync(); + string[] result; + // 旧对象格式缺少逐项阅读顺序,重新识别成功后再覆盖。 + if (cached?.RecognitionResult.TrimStart().StartsWith('[') == true) + result = JsonSerializer.Deserialize(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().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; + } + +} diff --git a/Plugins/CloudProduct/Services/QwenInspectionIntegration.cs b/Plugins/CloudProduct/Services/QwenInspectionIntegration.cs new file mode 100644 index 0000000..fb70fbe --- /dev/null +++ b/Plugins/CloudProduct/Services/QwenInspectionIntegration.cs @@ -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; + +/// 通过千问兼容接口识别上传图片中的文字及品牌。 +public sealed class QwenInspectionIntegration : IInspectionIntegration, ISingletonDependency, IDisposable +{ + private readonly HttpClient client; + private readonly string model; + + /// 按插件配置创建并复用模型客户端,定期更新池内连接以跟随 DNS 变化。 + public QwenInspectionIntegration(PluginContext 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 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(cancellationToken); + var content = body!.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString(); + var words = JsonSerializer.Deserialize(content ?? throw new JsonException("模型未返回识别内容。")); + if (words == null || words.Any(word => word == null)) throw new JsonException("模型必须返回 JSON 字符串数组。"); + return words; + } +} diff --git a/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs b/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs index 06cc7c1..dc1f032 100644 --- a/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs +++ b/Plugins/CloudProduct/Services/UserFavoritesIndexService.cs @@ -33,6 +33,9 @@ namespace Store.Modules.CloudProduct.Services /// 批量写入收藏文档(存在则覆盖) Task IndexDocumentsAsync(IEnumerable documents); + /// 更新单条收藏的安全字段并等待对检索可见。 + Task UpdateSafetyAsync(string documentId, bool isSafe); + /// 按文档Id批量删除收藏文档 Task DeleteDocumentsAsync(IEnumerable documentIds); @@ -172,6 +175,7 @@ namespace Store.Modules.CloudProduct.Services ) .Map(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(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))); diff --git a/Plugins/CloudProduct/Services/UserFavoritesService.cs b/Plugins/CloudProduct/Services/UserFavoritesService.cs index aada308..b65628c 100644 --- a/Plugins/CloudProduct/Services/UserFavoritesService.cs +++ b/Plugins/CloudProduct/Services/UserFavoritesService.cs @@ -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 /// 批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组) Task AddFavoritesAsync(AddUserFavoriteRequest request); + /// 按用户和商品编号更新已存在的收藏记录。 + Task SaveProductSafetyAsync(SaveProductSafetyRequest request); + /// 批量取消收藏,返回实际取消的条数 Task 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 _logger; @@ -54,13 +62,16 @@ namespace Store.Modules.CloudProduct.Services UserGoodsFavoriteRepository favoriteRepository, UserGoodsFavoriteGroupRepository groupRepository, GoodsRepository goodsRepository, + IUserQueryService queryService, IUserFavoritesIndexService indexService, - ILogger logger) + ILogger 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(); 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; } + /// 仅修改安全字段,避免覆盖同时发生的收藏分组变更。 + 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() + .Where(x => x.UserName == userName && x.GoodsId == goodsId).FirstAsync() + ?? throw new AppException("该用户尚未收藏此商品", ResultCode.NotFound); + + await work.Orm.Update().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(); + } + /// /// 批量取消收藏:先删除数据库记录,再删除索引文档; /// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。 @@ -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 /// public async Task 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, diff --git a/Plugins/CloudProduct/Store.CloudProduct.csproj b/Plugins/CloudProduct/Store.CloudProduct.csproj index 590a7d5..2c5e13d 100644 --- a/Plugins/CloudProduct/Store.CloudProduct.csproj +++ b/Plugins/CloudProduct/Store.CloudProduct.csproj @@ -11,8 +11,8 @@ + - diff --git a/Plugins/CloudProduct/appsettings.json b/Plugins/CloudProduct/appsettings.json index 2dc4888..9d7eca8 100644 --- a/Plugins/CloudProduct/appsettings.json +++ b/Plugins/CloudProduct/appsettings.json @@ -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" } } diff --git a/Plugins/Store.ImageCache/Controllers/ImageCacheController.cs b/Plugins/Store.ImageCache/Controllers/ImageCacheController.cs index 7245714..910713b 100644 --- a/Plugins/Store.ImageCache/Controllers/ImageCacheController.cs +++ b/Plugins/Store.ImageCache/Controllers/ImageCacheController.cs @@ -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; diff --git a/Store.API/Controllers/AuthController.cs b/Store.API/Controllers/AuthController.cs index 297be72..68b6387 100644 --- a/Store.API/Controllers/AuthController.cs +++ b/Store.API/Controllers/AuthController.cs @@ -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; diff --git a/Store.API/NoGoods.txt b/Store.API/NoGoods.txt new file mode 100644 index 0000000..c33e135 --- /dev/null +++ b/Store.API/NoGoods.txt @@ -0,0 +1 @@ +810910819204 \ No newline at end of file diff --git a/Store.API/Program.cs b/Store.API/Program.cs index 3418c31..87c53d8 100644 --- a/Store.API/Program.cs +++ b/Store.API/Program.cs @@ -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().AddOrUpdate( + "token-cleanup", service => service.CleanupExpiredTokens(), Cron.Daily(19), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); app.MapControllers(); diff --git a/Store.API/appsettings.json b/Store.API/appsettings.json index 404aa98..91d99b9 100644 --- a/Store.API/appsettings.json +++ b/Store.API/appsettings.json @@ -8,15 +8,25 @@ "ConnectionStrings": { "DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;", }, + // 登录认证:请求头使用 Authorization: Bearer 。 "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" } }, diff --git a/Store.Api.slnx b/Store.Api.slnx index 52ea083..8ef2664 100644 --- a/Store.Api.slnx +++ b/Store.Api.slnx @@ -9,6 +9,7 @@ + diff --git a/Tests/CloudProduct.Checks/CloudProduct.Checks.csproj b/Tests/CloudProduct.Checks/CloudProduct.Checks.csproj index 5ff3920..13086e6 100644 --- a/Tests/CloudProduct.Checks/CloudProduct.Checks.csproj +++ b/Tests/CloudProduct.Checks/CloudProduct.Checks.csproj @@ -3,6 +3,7 @@ Exe + diff --git a/Tests/CloudProduct.Checks/Program.cs b/Tests/CloudProduct.Checks/Program.cs index 39bbc4b..14ade0a 100644 --- a/Tests/CloudProduct.Checks/Program.cs +++ b/Tests/CloudProduct.Checks/Program.cs @@ -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(); + 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().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().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("""{"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("""{"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().Count() == 2, "Safety reports never create favorites"); +} var sql = db.Select().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 AcquireFavoritesAsync() => Task.FromResult(this); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/Tests/ImageAnalysisQuota.Checks/ImageAnalysisQuota.Checks.csproj b/Tests/ImageAnalysisQuota.Checks/ImageAnalysisQuota.Checks.csproj new file mode 100644 index 0000000..5ff3920 --- /dev/null +++ b/Tests/ImageAnalysisQuota.Checks/ImageAnalysisQuota.Checks.csproj @@ -0,0 +1,8 @@ + + + Exe + + + + + diff --git a/Tests/ImageAnalysisQuota.Checks/Program.cs b/Tests/ImageAnalysisQuota.Checks/Program.cs new file mode 100644 index 0000000..378ce5f --- /dev/null +++ b/Tests/ImageAnalysisQuota.Checks/Program.cs @@ -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().Features.Get()!.Addresses.Single(); +using var context = new PluginContext("CloudProduct", "Plugins/CloudProduct", builder.Environment); +context.Configuration["ImageAnalysisQuota:APIKey"] = "key+&=?"; +var typedContext = new PluginContext(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(() => service.UseImageAnalysisQuotaAsync(new() { Username = " " })); + context.Configuration["ImageAnalysisQuota:APIKey"] = ""; + await RejectAsync(() => service.UseImageAnalysisQuotaAsync(new() { Username = "test" })); + context.Configuration["ImageAnalysisQuota:APIKey"] = "key+&=?"; +} +if (calls != 2) throw new Exception("无效输入或配置不能发送扣减请求。"); +context.Configuration["ImageAnalysisQuota:Url"] = "file:///invalid"; +await RejectAsync(() => +{ + using var invalid = new ImageAnalysisQuotaService(typedContext); + return Task.CompletedTask; +}); +context.Configuration["ImageAnalysisQuota:Url"] = address; +using (var service = new ImageAnalysisQuotaService(typedContext)) +{ + status = 500; + await RejectAsync(() => service.UseImageAnalysisQuotaAsync(new() { Username = "用户 +&=?" })); +} +if (calls != 3) throw new Exception("失败请求不能自动重试。"); +await app.StopAsync(); +Console.WriteLine("Image analysis quota checks passed."); + +static async Task RejectAsync(Func action) where T : Exception +{ + try { await action(); } + catch (T) { return; } + throw new Exception($"应抛出 {typeof(T).Name}。"); +} diff --git a/Tests/PluginLoadingSmoke/Program.cs b/Tests/PluginLoadingSmoke/Program.cs index bbb93a9..f8b1cb3 100644 --- a/Tests/PluginLoadingSmoke/Program.cs +++ b/Tests/PluginLoadingSmoke/Program.cs @@ -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(); 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(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("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("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"); diff --git a/Tests/ProductInspection.Checks/HttpChecks.cs b/Tests/ProductInspection.Checks/HttpChecks.cs new file mode 100644 index 0000000..318eef9 --- /dev/null +++ b/Tests/ProductInspection.Checks/HttpChecks.cs @@ -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; + +/// 通过真实 HTTP 路由验证图片识别的 DTO 绑定、ApiResult 包装及缓存。 +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(context)); + builder.Services.AddKeyedSingleton("ProductInspectionAnalysisGate", (_, _) => new SemaphoreSlim(5, 5)); + builder.Services.AddKeyedSingleton("ProductInspectionConnection", database); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + builder.Services.AddAuthentication("test").AddScheme("test", _ => { }); + builder.Services.AddAuthorization(); + builder.Services.AddControllers(options => + { + options.Conventions.Add(new MAX.Framework.Conventions.ApiRoutePrefixConvention()); + options.Filters.Add(); + options.Filters.Add(); + }).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().Features.Get()!.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 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()!.SequenceEqual(expected), "Ordered recognition array"); + Check((await Inspect(image, key)).GetProperty("data").Deserialize()!.SequenceEqual(expected), "Cache hit preserves item order"); + var stored = await database.Select().Where(x => x.CacheKey == key).FirstAsync(); + Check(stored.RecognitionResult == """["急速发货","螺旋伸缩","金属挂钩","可调节","不挡扇叶","倒挂钩卡口"]""", "Cache stores distinct items with readable Chinese"); + await database.Update().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().Set(x => x.RecognitionResult, """{"text":"legacy","logoBrands":[]}""") + .Where(x => x.CacheKey == key).ExecuteAffrowsAsync(); + Check((await Inspect(image, key)).GetProperty("data").Deserialize()!.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().Where(x => x.CacheKey == key).ExecuteAffrowsAsync(); + } + } +} + +/// 仅测试宿主使用的身份处理器,生产插件始终沿用宿主 Bearer 认证。 +internal sealed class TestAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task 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))); + } +} + +/// 返回模型协议示例,使 HTTP 检查无需外部密钥。 +internal static class RecognitionEndpoint +{ + private const string Content = """["\u6025\u901F\u53D1\u8D27","急速发货","螺旋伸缩","急速发货","金属挂钩","可调节","不挡扇叶","倒挂钩卡口"]"""; + + internal static async Task 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 } } } }); + } +} diff --git a/Tests/ProductInspection.Checks/ProductInspection.Checks.csproj b/Tests/ProductInspection.Checks/ProductInspection.Checks.csproj new file mode 100644 index 0000000..c91909a --- /dev/null +++ b/Tests/ProductInspection.Checks/ProductInspection.Checks.csproj @@ -0,0 +1,4 @@ + + Exeenable + + diff --git a/Tests/ProductInspection.Checks/Program.cs b/Tests/ProductInspection.Checks/Program.cs new file mode 100644 index 0000000..8d56a57 --- /dev/null +++ b/Tests/ProductInspection.Checks/Program.cs @@ -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(tableQuery); + if (!existing.Contains("image_analysis_cache")) db.CodeFirst.SyncStructure(typeof(ImageAnalysisCache)); + var columns = await db.Ado.QueryAsync(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().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(tableQuery); +Console.WriteLine(string.Join(", ", tables)); +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" })) + 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); diff --git a/Tests/Store.Security.Tests/PersistentSessionTests.cs b/Tests/Store.Security.Tests/PersistentSessionTests.cs index 4cfa47f..4914fd0 100644 --- a/Tests/Store.Security.Tests/PersistentSessionTests.cs +++ b/Tests/Store.Security.Tests/PersistentSessionTests.cs @@ -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().Count()); + Assert.True(db.Select().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().ToList()); + } + [Fact] public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry() { diff --git a/Tests/Store.Users.Tests/UserManagementTests.cs b/Tests/Store.Users.Tests/UserManagementTests.cs index 4afcdba..335a0ad 100644 --- a/Tests/Store.Users.Tests/UserManagementTests.cs +++ b/Tests/Store.Users.Tests/UserManagementTests.cs @@ -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().Set(u => u.Authority, "订单").Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync(); Assert.DoesNotContain("积分用户", (await context.Query.GetUser("child")).Authority); diff --git a/Tests/Store.Users.Tests/UserRepositoryTests.cs b/Tests/Store.Users.Tests/UserRepositoryTests.cs new file mode 100644 index 0000000..de5e8e3 --- /dev/null +++ b/Tests/Store.Users.Tests/UserRepositoryTests.cs @@ -0,0 +1,61 @@ +using MAX.Framework.Commons; +using Store.Users.Entities; +using Store.Users.Repositories; +using Store.Users.Services; + +namespace Store.Users.Tests; + +/// 验证主账号解析及无效父账号的继承边界。 +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().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().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(() => repository.GetUser("child")); + await Assert.ThrowsAsync(() => repository.GetMainUser("child")); + } +} diff --git a/Tests/UserManagementSmoke/Program.cs b/Tests/UserManagementSmoke/Program.cs index b595618..b257c2f 100644 --- a/Tests/UserManagementSmoke/Program.cs +++ b/Tests/UserManagementSmoke/Program.cs @@ -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));