云库增加 AI识图,缓存,我的云库收藏增加安全标志
调用外部API 扣费
This commit is contained in:
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>();
|
provider.GetRequiredService<PluginRuntime>();
|
||||||
return context;
|
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}",
|
RunDebugStep("Plugin", $"注册 {plugin.Id} ({plugin.GetType().FullName}),Priority={plugin.Priority}",
|
||||||
() => plugin.ConfigureServices(host.Services, context));
|
() => plugin.ConfigureServices(host.Services, context));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
设备数为 0 时无限制,为 1 时单点登录,为 N 时保留最多 N 个有效会话,超限撤销最早的会话。
|
设备数为 0 时无限制,为 1 时单点登录,为 N 时保留最多 N 个有效会话,超限撤销最早的会话。
|
||||||
签发在事务中锁定现有用户行,使多实例的并发登录也遵守数量限制。MySQL 业务用户表需使用 InnoDB。
|
签发在事务中锁定现有用户行,使多实例的并发登录也遵守数量限制。MySQL 业务用户表需使用 InnoDB。
|
||||||
退出当前会话、修改密码后的全部会话撤销以及设备替换统一更新 Revoked 并清除当前实例缓存。
|
退出当前会话、修改密码后的全部会话撤销以及设备替换统一更新 Revoked 并清除当前实例缓存。
|
||||||
|
宿主注册 Hangfire 任务 `token-cleanup`,每天北京时间 03:00(UTC 19:00)删除已撤销、闲置到期或达到绝对期限的记录;可在 `/hangfire` 查看执行结果。任务执行需要宿主运行。
|
||||||
|
|
||||||
多实例下其他实例最多延迟 CacheSeconds 秒获知撤销;要求每次查库时设置 CacheSeconds=0。
|
多实例下其他实例最多延迟 CacheSeconds 秒获知撤销;要求每次查库时设置 CacheSeconds=0。
|
||||||
用户信息在缓存未命中时从实际 `wxxd.user` 表读取 id/user_name/nick_name;当前用户表没有 IsEnabled 或 Role 字段,因此未假设这些字段或新增角色策略。
|
用户信息在缓存未命中时从实际 `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)
|
internal void Revoke(string userName, string? sessionId = null)
|
||||||
{
|
{
|
||||||
lock (Gate)
|
lock (Gate)
|
||||||
|
|||||||
@@ -37,3 +37,20 @@ public sealed class PluginContext : IDisposable
|
|||||||
(Configuration as IDisposable)?.Dispose();
|
(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
|
public interface IUserQueryService
|
||||||
{
|
{
|
||||||
|
Task<UserInfo> GetMainUser(string userName);
|
||||||
Task<UserInfo> GetUser(string userName);
|
Task<UserInfo> GetUser(string userName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
|
using MAX.Framework.Conventions;
|
||||||
|
using MAX.Framework.Dtos;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Store.Framework.Base;
|
||||||
using Store.Users.Commands;
|
using Store.Users.Commands;
|
||||||
using Store.Users.Contracts.Dtos;
|
using Store.Users.Contracts.Dtos;
|
||||||
using Store.Users.Dtos;
|
using Store.Users.Dtos;
|
||||||
using Store.Users.Entities;
|
using Store.Users.Entities;
|
||||||
using Store.Users.Services;
|
using Store.Users.Services;
|
||||||
using MAX.Framework.Dtos;
|
|
||||||
using MAX.Framework.Conventions;
|
|
||||||
|
|
||||||
namespace Store.Users.Controllers;
|
namespace Store.Users.Controllers;
|
||||||
|
|
||||||
/// <summary>用户模块接口;所有管理操作均以当前登录身份对应的数据库账户进行授权。</summary>
|
/// <summary>用户模块接口;所有管理操作均以当前登录身份对应的数据库账户进行授权。</summary>
|
||||||
[ApiController]
|
|
||||||
[DefaultHttpPost]
|
[DefaultHttpPost]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("v{version:apiVersion}/user/[action]")]
|
[Route("[controller]/[action]")]
|
||||||
public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator) : ControllerBase
|
public class UserController(UserManagementService users, UserFundsService funds, IMediator mediator) : AppControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>获取本人或授权范围内的用户信息,响应不包含密码。</summary>
|
/// <summary>获取本人或授权范围内的用户信息,响应不包含密码。</summary>
|
||||||
[EndpointSummary("获取用户信息")]
|
[EndpointSummary("获取用户信息")]
|
||||||
|
|||||||
@@ -5,22 +5,28 @@ namespace Store.Users.Repositories;
|
|||||||
|
|
||||||
public interface IUserRepository : IBaseRepository<UserEntity>
|
public interface IUserRepository : IBaseRepository<UserEntity>
|
||||||
{
|
{
|
||||||
|
/// <summary>获取用户及从主账号继承的有效配置;用户不存在时返回空。</summary>
|
||||||
Task<UserEntity?> GetUser(string userName);
|
Task<UserEntity?> GetUser(string userName);
|
||||||
|
|
||||||
|
/// <summary>获取用户所属主账号(主账号返回自身);用户不存在时返回空,主账号无效时抛出业务异常。</summary>
|
||||||
|
Task<UserEntity?> GetMainUser(string userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>集中处理旧系统主子账号的有效计费配置和积分权限继承。</summary>
|
/// <summary>集中处理旧系统主子账号的有效计费配置和积分权限继承。</summary>
|
||||||
internal class UserRepository(IFreeSql freeSql) : BaseRepository<UserEntity>(freeSql), IUserRepository, ITransientDependency
|
internal class UserRepository(IFreeSql freeSql) : BaseRepository<UserEntity>(freeSql), IUserRepository, ITransientDependency
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task<UserEntity?> GetUser(string userName)
|
public async Task<UserEntity?> GetUser(string userName)
|
||||||
{
|
{
|
||||||
var user = await Where(u => u.UserName == userName).FirstAsync();
|
var user = await Where(u => u.UserName == userName).FirstAsync();
|
||||||
if (user == null || user.IsMainUser) return user;
|
if (user == null || user.IsMainUser) return user;
|
||||||
var parent = await Where(u => u.UserName == user.ParentUserName).FirstAsync();
|
var parent = await GetMainUser(user);
|
||||||
if (parent == null) throw new MAX.Framework.Commons.AppException("主账号不存在,请联系管理员");
|
|
||||||
user.OemId = parent.OemId;
|
user.OemId = parent.OemId;
|
||||||
user.PostagePerOrder = Math.Max(2.2m, parent.PostagePerOrder ?? 0);
|
// 子账号的计费配置完全跟随主账号,包括未配置的费用。
|
||||||
user.RefundPostagePerOrder = Math.Max(2.5m, parent.RefundPostagePerOrder ?? 0);
|
user.ShopPointsPerMonth = parent.ShopPointsPerMonth;
|
||||||
user.GiftOemAmountPerOrder = Math.Max(0, parent.GiftOemAmountPerOrder ?? 0);
|
user.PostagePerOrder = parent.PostagePerOrder;
|
||||||
|
user.RefundPostagePerOrder = parent.RefundPostagePerOrder;
|
||||||
|
user.GiftOemAmountPerOrder = parent.GiftOemAmountPerOrder;
|
||||||
user.SyncLogisticsPerOrder = parent.SyncLogisticsPerOrder;
|
user.SyncLogisticsPerOrder = parent.SyncLogisticsPerOrder;
|
||||||
var authority = (user.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
|
var authority = (user.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
|
||||||
var parentAuthority = (parent.Authority ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
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);
|
user.Authority = string.Join(",", authority);
|
||||||
return user;
|
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("用户不存在");
|
var user = await repository.GetUser(userName.Trim()) ?? throw new AppException("用户不存在");
|
||||||
return new UserMapper().ToInfo(user);
|
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)
|
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)
|
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connectionStringBuilder.ConnectionString)
|
||||||
.UseAdoConnectionPool(true).Build());
|
.UseAdoConnectionPool(true).Build());
|
||||||
services.AddHostedService<CloudProductSearchStartup>();
|
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>
|
/// <summary>云商品管理:商品查询、导入、违规上报及搜索索引维护。</summary>
|
||||||
[Tags("云商品")]
|
[Tags("云商品")]
|
||||||
[ApiVersionNeutral]
|
[Route("CloudGoods/[action]")]
|
||||||
[Route("api/CloudGoods/[action]")]
|
|
||||||
public class GoodsController : AppControllerBase
|
public class GoodsController : AppControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGoodsService _goodsService;
|
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);
|
return await _favoritesService.AddFavoritesAsync(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>更新用户已收藏商品的安全结论。</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ApiResult> SaveProductSafety(SaveProductSafetyRequest request)
|
||||||
|
{
|
||||||
|
await _favoritesService.SaveProductSafetyAsync(request);
|
||||||
|
return ApiResult.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。
|
/// 批量取消收藏:删除 user_goods_favorites 记录并同步删除收藏索引文档,返回实际取消的条数。
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>收藏时间</summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 安全检测
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsSafe { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,10 @@ namespace Store.Modules.CloudProduct.Dtos
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserFavoritesSearchDocument
|
public class UserFavoritesSearchDocument
|
||||||
{
|
{
|
||||||
|
/// <summary>安全检测结论;为空表示尚未检测。</summary>
|
||||||
|
[Boolean(Name = "is_safe")]
|
||||||
|
public bool? IsSafe { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id}
|
/// 文档主键(同时作为 OpenSearch 的 _id):{user_name}:{goods_id}
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ namespace Store.Modules.CloudProduct.Dtos
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserFavoritesSearchRequest
|
public class UserFavoritesSearchRequest
|
||||||
{
|
{
|
||||||
|
/// <summary>安全状态:0 全部(默认),1 安全,2 违规,3 未检测。</summary>
|
||||||
|
public int SafeStatus { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户名(为空时取当前登录用户)
|
/// 用户名(为空时取当前登录用户)
|
||||||
/// </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)]
|
[Column(Name = "goods_id", IsNullable = false)]
|
||||||
public long GoodsId { get; set; }
|
public long GoodsId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>最近上报的安全结论,null 表示尚未检测。</summary>
|
||||||
|
[Column(Name = "is_safe", IsNullable = true)]
|
||||||
|
public bool? IsSafe { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 收藏时间
|
/// 收藏时间
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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>
|
/// <summary>跨实例串行化违规上报和商品索引写入,防止下架后被旧批次写回。</summary>
|
||||||
public class GoodsIndexLock([FromKeyedServices("CloudProduct")] PluginContext context) : ISingletonDependency
|
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"])
|
var settings = new MySqlConnectionStringBuilder(context.Configuration["CloudProductConnection"])
|
||||||
@@ -19,7 +24,7 @@ public class GoodsIndexLock([FromKeyedServices("CloudProduct")] PluginContext co
|
|||||||
Pooling = false
|
Pooling = false
|
||||||
};
|
};
|
||||||
var connection = new MySqlConnection(settings.ConnectionString);
|
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];
|
Encoding.UTF8.GetBytes(settings.Database)))[..40];
|
||||||
try
|
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>
|
/// <summary>批量写入收藏文档(存在则覆盖)</summary>
|
||||||
Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents);
|
Task IndexDocumentsAsync(IEnumerable<UserFavoritesSearchDocument> documents);
|
||||||
|
|
||||||
|
/// <summary>更新单条收藏的安全字段并等待对检索可见。</summary>
|
||||||
|
Task UpdateSafetyAsync(string documentId, bool isSafe);
|
||||||
|
|
||||||
/// <summary>按文档Id批量删除收藏文档</summary>
|
/// <summary>按文档Id批量删除收藏文档</summary>
|
||||||
Task DeleteDocumentsAsync(IEnumerable<string> documentIds);
|
Task DeleteDocumentsAsync(IEnumerable<string> documentIds);
|
||||||
|
|
||||||
@@ -172,6 +175,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
)
|
)
|
||||||
.Map<UserFavoritesSearchDocument>(m => m
|
.Map<UserFavoritesSearchDocument>(m => m
|
||||||
.Properties(p => p
|
.Properties(p => p
|
||||||
|
.Boolean(b => b.Name(x => x.IsSafe))
|
||||||
.Keyword(k => k.Name(x => x.Id))
|
.Keyword(k => k.Name(x => x.Id))
|
||||||
.Number(n => n.Name(x => x.FavId).Type(NumberType.Long))
|
.Number(n => n.Name(x => x.FavId).Type(NumberType.Long))
|
||||||
.Keyword(k => k.Name(x => x.UserName))
|
.Keyword(k => k.Name(x => x.UserName))
|
||||||
@@ -221,6 +225,14 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
return await CreateIndexAsync();
|
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()
|
private async Task RefreshIndexAsync()
|
||||||
{
|
{
|
||||||
var response = await _client.Indices.RefreshAsync(_indexName, d => d
|
var response = await _client.Indices.RefreshAsync(_indexName, d => d
|
||||||
@@ -316,6 +328,9 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
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;
|
var keyword = request.Title;
|
||||||
int? groupId = request.GroupId;
|
int? groupId = request.GroupId;
|
||||||
var groupName = request.GroupName;
|
var groupName = request.GroupName;
|
||||||
@@ -341,6 +356,12 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
// 只检索当前用户自己的收藏 —— 放进 filters,避免被后面的 Filter 覆盖
|
// 只检索当前用户自己的收藏 —— 放进 filters,避免被后面的 Filter 覆盖
|
||||||
filters.Add(f => f.Term(t => t.Field(x => x.UserName).Value(request.UserName)));
|
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)
|
if (groupId.HasValue)
|
||||||
{
|
{
|
||||||
filters.Add(f => f.Term(t => t.Field(x => x.GroupId).Value(groupId.Value)));
|
filters.Add(f => f.Term(t => t.Field(x => x.GroupId).Value(groupId.Value)));
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using MAX.Core.DependencyInjection;
|
using MAX.Core.DependencyInjection;
|
||||||
using MAX.Framework.Commons;
|
using MAX.Framework.Commons;
|
||||||
using MAX.Framework.Dtos;
|
using MAX.Framework.Dtos;
|
||||||
|
using MAX.Framework.Validation;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Store.Modules.CloudProduct.Dtos;
|
using Store.Modules.CloudProduct.Dtos;
|
||||||
using Store.Modules.CloudProduct.Entities;
|
using Store.Modules.CloudProduct.Entities;
|
||||||
using Store.Modules.CloudProduct.Enums;
|
using Store.Modules.CloudProduct.Enums;
|
||||||
using Store.Modules.CloudProduct.Repositorys;
|
using Store.Modules.CloudProduct.Repositorys;
|
||||||
|
using Store.Users.Contracts;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace Store.Modules.CloudProduct.Services
|
namespace Store.Modules.CloudProduct.Services
|
||||||
{
|
{
|
||||||
@@ -17,6 +20,9 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
/// <summary>批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组)</summary>
|
/// <summary>批量收藏商品,返回本次成功收藏的商品数量(已收藏的商品视为修改收藏分组)</summary>
|
||||||
Task<int> AddFavoritesAsync(AddUserFavoriteRequest request);
|
Task<int> AddFavoritesAsync(AddUserFavoriteRequest request);
|
||||||
|
|
||||||
|
/// <summary>按用户和商品编号更新已存在的收藏记录。</summary>
|
||||||
|
Task SaveProductSafetyAsync(SaveProductSafetyRequest request);
|
||||||
|
|
||||||
/// <summary>批量取消收藏,返回实际取消的条数</summary>
|
/// <summary>批量取消收藏,返回实际取消的条数</summary>
|
||||||
Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request);
|
Task<int> RemoveFavoritesAsync(RemoveUserFavoritesRequest request);
|
||||||
|
|
||||||
@@ -47,6 +53,8 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
private readonly UserGoodsFavoriteRepository _favoriteRepository;
|
private readonly UserGoodsFavoriteRepository _favoriteRepository;
|
||||||
private readonly UserGoodsFavoriteGroupRepository _groupRepository;
|
private readonly UserGoodsFavoriteGroupRepository _groupRepository;
|
||||||
private readonly GoodsRepository _goodsRepository;
|
private readonly GoodsRepository _goodsRepository;
|
||||||
|
private readonly IUserQueryService _queryService;
|
||||||
|
private readonly GoodsIndexLock _indexLock;
|
||||||
private readonly IUserFavoritesIndexService _indexService;
|
private readonly IUserFavoritesIndexService _indexService;
|
||||||
private readonly ILogger<UserFavoritesService> _logger;
|
private readonly ILogger<UserFavoritesService> _logger;
|
||||||
|
|
||||||
@@ -54,13 +62,16 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
UserGoodsFavoriteRepository favoriteRepository,
|
UserGoodsFavoriteRepository favoriteRepository,
|
||||||
UserGoodsFavoriteGroupRepository groupRepository,
|
UserGoodsFavoriteGroupRepository groupRepository,
|
||||||
GoodsRepository goodsRepository,
|
GoodsRepository goodsRepository,
|
||||||
|
IUserQueryService queryService,
|
||||||
IUserFavoritesIndexService indexService,
|
IUserFavoritesIndexService indexService,
|
||||||
ILogger<UserFavoritesService> logger)
|
ILogger<UserFavoritesService> logger, GoodsIndexLock indexLock)
|
||||||
{
|
{
|
||||||
_favoriteRepository = favoriteRepository;
|
_favoriteRepository = favoriteRepository;
|
||||||
_groupRepository = groupRepository;
|
_groupRepository = groupRepository;
|
||||||
_goodsRepository = goodsRepository;
|
_goodsRepository = goodsRepository;
|
||||||
|
_queryService = queryService;
|
||||||
_indexService = indexService;
|
_indexService = indexService;
|
||||||
|
_indexLock = indexLock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +92,8 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
{
|
{
|
||||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
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>();
|
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
|
||||||
if (goodsIds.Count == 0)
|
if (goodsIds.Count == 0)
|
||||||
@@ -93,6 +106,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
throw new AppException("收藏分组无效", ResultCode.ValidationError);
|
throw new AppException("收藏分组无效", ResultCode.ValidationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await using var lease = await _indexLock.AcquireFavoritesAsync();
|
||||||
// 1. 校验分组归属(分组ID > 0 时必须是该用户自己创建的分组)
|
// 1. 校验分组归属(分组ID > 0 时必须是该用户自己创建的分组)
|
||||||
if (request.GroupId > 0)
|
if (request.GroupId > 0)
|
||||||
{
|
{
|
||||||
@@ -206,6 +220,28 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
return documents.Count;
|
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>
|
/// <summary>
|
||||||
/// 批量取消收藏:先删除数据库记录,再删除索引文档;
|
/// 批量取消收藏:先删除数据库记录,再删除索引文档;
|
||||||
/// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。
|
/// 索引删除失败时回补数据库记录,避免“库里有收藏但搜索不到”。
|
||||||
@@ -229,6 +265,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
|
throw new AppException("请提供要取消收藏的商品编号", ResultCode.ValidationError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await using var lease = await _indexLock.AcquireFavoritesAsync();
|
||||||
var favorites = await _favoriteRepository.Select
|
var favorites = await _favoriteRepository.Select
|
||||||
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -404,8 +441,8 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
{
|
{
|
||||||
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||||
}
|
}
|
||||||
|
var mainUser = await _queryService.GetMainUser(userName);
|
||||||
request.UserName = userName;
|
request.UserName = mainUser.UserName;
|
||||||
|
|
||||||
var searchResult = await _indexService.SearchAsync(request, ParseSortBy(request.SortBy));
|
var searchResult = await _indexService.SearchAsync(request, ParseSortBy(request.SortBy));
|
||||||
var list = searchResult.Documents.Select(ToItem).ToList();
|
var list = searchResult.Documents.Select(ToItem).ToList();
|
||||||
@@ -417,6 +454,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<long> RebuildSearchIndexAsync()
|
public async Task<long> RebuildSearchIndexAsync()
|
||||||
{
|
{
|
||||||
|
await using var lease = await _indexLock.AcquireFavoritesAsync();
|
||||||
// 1. 删除并重建索引(清空所有用户收藏索引)
|
// 1. 删除并重建索引(清空所有用户收藏索引)
|
||||||
await _indexService.ClearIndexAsync();
|
await _indexService.ClearIndexAsync();
|
||||||
|
|
||||||
@@ -546,6 +584,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
{
|
{
|
||||||
Id = UserFavoritesSearchDocument.BuildId(favorite.UserName, favorite.GoodsId),
|
Id = UserFavoritesSearchDocument.BuildId(favorite.UserName, favorite.GoodsId),
|
||||||
FavId = favorite.Id,
|
FavId = favorite.Id,
|
||||||
|
IsSafe = favorite.IsSafe,
|
||||||
UserName = favorite.UserName,
|
UserName = favorite.UserName,
|
||||||
GroupId = favorite.GroupId,
|
GroupId = favorite.GroupId,
|
||||||
GroupName = groupName,
|
GroupName = groupName,
|
||||||
@@ -568,6 +607,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
return new UserFavoriteItem
|
return new UserFavoriteItem
|
||||||
{
|
{
|
||||||
Id = document.FavId,
|
Id = document.FavId,
|
||||||
|
IsSafe = document.IsSafe,
|
||||||
UserName = document.UserName,
|
UserName = document.UserName,
|
||||||
GroupId = document.GroupId,
|
GroupId = document.GroupId,
|
||||||
GroupName = document.GroupName,
|
GroupName = document.GroupName,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Modules\Store.Plugins\Store.Plugins.csproj" />
|
||||||
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
||||||
<ProjectReference Include="..\..\Core\MAX.Plugins\MAX.Plugins.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"FavoritesIndexName": "user_goods_favorites_search"
|
"FavoritesIndexName": "user_goods_favorites_search"
|
||||||
},
|
},
|
||||||
"RawData": {
|
"RawData": {
|
||||||
"Path": "E:\\GoodsRawData"
|
"Path": "Z:\\GoodsRawData"
|
||||||
},
|
},
|
||||||
"Indexing": {
|
"Indexing": {
|
||||||
"Mode": "Background",
|
"Mode": "Background",
|
||||||
@@ -15,5 +15,16 @@
|
|||||||
},
|
},
|
||||||
"Delete": {
|
"Delete": {
|
||||||
"SoftDelete": true
|
"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
|
namespace KJD_Web.Controllers
|
||||||
{
|
{
|
||||||
[ApiVersionNeutral]
|
[Route("[controller]/[action]")]
|
||||||
[Route("api/ImageCache/[action]")]
|
|
||||||
public class ImageCacheController : AppControllerBase
|
public class ImageCacheController : AppControllerBase
|
||||||
{
|
{
|
||||||
private readonly ImageCacheService _imageCacheService;
|
private readonly ImageCacheService _imageCacheService;
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ using Store.Users.Contracts;
|
|||||||
namespace Store.Api.Controllers
|
namespace Store.Api.Controllers
|
||||||
{
|
{
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[ApiVersion("1.0")]
|
[Route("[controller]/[action]")]
|
||||||
[Route("/v{version:apiVersion}/[controller]/[action]")]
|
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserAuthenticationService _users;
|
private readonly IUserAuthenticationService _users;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
810910819204
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using Asp.Versioning.OpenApi;
|
using Asp.Versioning.OpenApi;
|
||||||
|
using Hangfire;
|
||||||
using MAX.Framework.Conventions;
|
using MAX.Framework.Conventions;
|
||||||
using MAX.Framework.DependencyInjection;
|
using MAX.Framework.DependencyInjection;
|
||||||
|
using MAX.Framework.Security;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
public partial class Program
|
public partial class Program
|
||||||
@@ -80,6 +82,10 @@ public partial class Program
|
|||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseStoreHangfire();
|
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();
|
app.MapControllers();
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,25 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
|
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
|
||||||
},
|
},
|
||||||
|
// 登录认证:请求头使用 Authorization: Bearer <accessToken>。
|
||||||
"TokenAuth": {
|
"TokenAuth": {
|
||||||
|
// 闲置有效期(天):从签发或最近一次续期起计算。
|
||||||
"IdleDays": 7,
|
"IdleDays": 7,
|
||||||
|
// 最长有效期(天):从签发起计算,到期必须重新登录。
|
||||||
"AbsoluteMaxDays": 30,
|
"AbsoluteMaxDays": 30,
|
||||||
|
// 续期间隔(分钟):有效请求间隔达到此值才续期,不能超过最长有效期。
|
||||||
"RenewIntervalMinutes": 10,
|
"RenewIntervalMinutes": 10,
|
||||||
|
// 本机认证缓存(秒):0 不缓存;跨实例撤销最多延迟此时长生效。
|
||||||
"CacheSeconds": 30,
|
"CacheSeconds": 30,
|
||||||
|
// 有效会话上限:0 不限制;超限踢出最早会话,相同 deviceId 替换旧会话。
|
||||||
"MaxDevicesPerUser": 0,
|
"MaxDevicesPerUser": 0,
|
||||||
|
// 开发固定令牌:仅 Development 环境生效,不受上述限制。
|
||||||
"DevelopmentToken": {
|
"DevelopmentToken": {
|
||||||
|
// 是否启用;appsettings.Development.json 中覆盖为 true。
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
// 调试用 Bearer 令牌,不能为空,区分大小写。
|
||||||
"Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd",
|
"Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd",
|
||||||
|
// 调试身份的用户名,不能为空,建议填写已有账号。
|
||||||
"UserName": "18990991234"
|
"UserName": "18990991234"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
|
<Project Path="Core/MAX.Framework/MAX.Framework.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Modules/">
|
<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" />
|
<Project Path="Modules/Store.Shops.Contracts/Store.Shops.Contracts.csproj" Id="7d313823-f86a-450a-be29-8f915fc4b5b3" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.311" />
|
||||||
<ProjectReference Include="../../Plugins/CloudProduct/Store.CloudProduct.csproj" />
|
<ProjectReference Include="../../Plugins/CloudProduct/Store.CloudProduct.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -56,6 +56,77 @@ Reject(() => GoodsViolationService.ValidateAndGroup([new() { GoodsId = 1, Reason
|
|||||||
// SQL generation only: no database connection or production data access.
|
// SQL generation only: no database connection or production data access.
|
||||||
using var db = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql,
|
using var db = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql,
|
||||||
"Server=127.0.0.1;Database=checks;User Id=unused;Password=unused").Build();
|
"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")
|
var sql = db.Select<GoodsEntity>().As("g")
|
||||||
.Where(x => x.Status == 0 && x.IsSegmented == 0)
|
.Where(x => x.Status == 0 && x.IsSegmented == 0)
|
||||||
.Where("NOT EXISTS (SELECT 1 FROM goods_violation v WHERE v.goods_id = g.goods_id)")
|
.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":true,"items":[{"delete":{"_index":"goods","_id":"123","status":429,"error":{"type":"rejected","reason":"busy"}}}]}""", true);
|
||||||
await TestDelete(200, """{"errors":false,"items":[]}""", true);
|
await TestDelete(200, """{"errors":false,"items":[]}""", true);
|
||||||
Console.WriteLine($"PASS: {checks} checks (no external services accessed)");
|
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}。");
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ catch (TargetInvocationException ex) when (mode == "failure" && ex.InnerExceptio
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode == "failure") throw new Exception("Expected loading failure");
|
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 (mode == "empty")
|
||||||
{
|
{
|
||||||
if (plugins.Length != 0) throw new Exception("Unexpected plugins");
|
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")
|
if (mode is "image" or "plugins")
|
||||||
{
|
{
|
||||||
var host = Host.CreateApplicationBuilder();
|
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)!;
|
var core = typeof(ServiceCollectionExtensions).GetMethod("AddStoreCore", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||||
host.Configuration["Search:Url"] = "https://host.invalid";
|
host.Configuration["Search:Url"] = "https://host.invalid";
|
||||||
host.Configuration["ImageCache:ConnectionString"] = "host-only";
|
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)))
|
if (host.Services.Any(d => d.ServiceType == typeof(Hangfire.JobStorage)))
|
||||||
throw new Exception("Plugins must not register shared Hangfire storage");
|
throw new Exception("Plugins must not register shared Hangfire storage");
|
||||||
using var built = host.Build();
|
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");
|
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["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 (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 (host.Configuration["ImageCache:ConnectionString"] != "host-only") throw new Exception("Plugin modified host configuration");
|
||||||
if (mode == "plugins")
|
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");
|
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 (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");
|
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>
|
||||||
@@ -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));
|
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]
|
[Fact]
|
||||||
public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry()
|
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();
|
.Set(u => u.SyncLogisticsPerOrder, 0.5m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
|
||||||
var info = await context.Query.GetUser("child");
|
var info = await context.Query.GetUser("child");
|
||||||
Assert.Equal("brand", info.OemId);
|
Assert.Equal("brand", info.OemId);
|
||||||
Assert.Equal(2.2m, info.PostagePerOrder);
|
Assert.Equal(1m, info.PostagePerOrder);
|
||||||
Assert.Equal(2.5m, info.RefundPostagePerOrder);
|
Assert.Equal(1m, info.RefundPostagePerOrder);
|
||||||
Assert.Equal(0m, info.GiftOemAmountPerOrder);
|
Assert.Equal(-1m, info.GiftOemAmountPerOrder);
|
||||||
Assert.Equal(0.5m, info.SyncLogisticsPerOrder);
|
Assert.Equal(0.5m, info.SyncLogisticsPerOrder);
|
||||||
await context.Db.Update<UserEntity>().Set(u => u.Authority, "订单").Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
|
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);
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ try
|
|||||||
actor.UserName = main.UserName;
|
actor.UserName = main.UserName;
|
||||||
var sub = await users.Create(new() { UserName = "child", Password = "child-password", Authority = "积分用户" }, true, default);
|
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.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", Password = "password", Authority = "超级权限" }, true, default));
|
||||||
await Reject(() => users.Create(new() { UserName = "bad-admin", Password = "password", SaLevel = 1 }, 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));
|
await Reject(() => users.Update(sub.Id, new() { ShopPointsPerMonth = 30 }, true, default));
|
||||||
|
|||||||
Reference in new issue
Block a user