Compare commits
8
Commits
1fa91a9cfb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc35bb2d6 | ||
|
|
b18aad1f85 | ||
|
|
f4913b4fc2 | ||
|
|
9bb17a67d2 | ||
|
|
150e8644b7 | ||
|
|
7c29e57623 | ||
|
|
33ef90a5cc | ||
|
|
207696d417 |
No files matched your search
@@ -0,0 +1,55 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using Hangfire.MySql;
|
||||||
|
using HangfireBasicAuthenticationFilter;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
|
namespace MAX.Framework.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>宿主共享的后台任务存储、执行服务与看板入口。</summary>
|
||||||
|
public static class HangfireServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>从宿主配置注册 Hangfire;任务数据库由独立的 FreeSql 单例管理。</summary>
|
||||||
|
public static IServiceCollection AddStoreHangfire(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "Hangfire:ConnectionString", "Hangfire:Dashboard:User", "Hangfire:Dashboard:Pass" })
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuration[key]))
|
||||||
|
throw new InvalidOperationException($"缺少宿主配置 {key},无法启动 Hangfire。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var connection = new MySqlConnectionStringBuilder(configuration["Hangfire:ConnectionString"])
|
||||||
|
{
|
||||||
|
// Hangfire MySQL 存储的建表和查询语句依赖用户变量。
|
||||||
|
AllowUserVariables = true
|
||||||
|
};
|
||||||
|
services.AddKeyedSingleton<IFreeSql>("HangfireConnection", (_, _) =>
|
||||||
|
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection.ConnectionString)
|
||||||
|
.UseAdoConnectionPool(true).Build());
|
||||||
|
services.AddHangfire((provider, config) => config
|
||||||
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
|
.UseSimpleAssemblyNameTypeSerializer()
|
||||||
|
.UseRecommendedSerializerSettings()
|
||||||
|
.UseStorage(new MySqlStorage(provider.GetRequiredKeyedService<IFreeSql>("HangfireConnection"),
|
||||||
|
new MySqlStorageOptions { TablesPrefix = "Hangfire_" })));
|
||||||
|
services.AddHangfireServer();
|
||||||
|
services.AddSingleton(new DashboardOptions
|
||||||
|
{
|
||||||
|
Authorization = new[]
|
||||||
|
{
|
||||||
|
new HangfireCustomBasicAuthenticationFilter
|
||||||
|
{
|
||||||
|
User = configuration["Hangfire:Dashboard:User"],
|
||||||
|
Pass = configuration["Hangfire:Dashboard:Pass"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>在宿主认证、授权中间件之后挂载共享看板。</summary>
|
||||||
|
public static IApplicationBuilder UseStoreHangfire(this IApplicationBuilder app)
|
||||||
|
=> app.UseHangfireDashboard("/hangfire", app.ApplicationServices.GetRequiredService<DashboardOptions>());
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||||
<PackageReference Include="Asp.Versioning.OpenApi" Version="10.2.1" />
|
<PackageReference Include="Asp.Versioning.OpenApi" Version="10.2.1" />
|
||||||
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
||||||
|
<PackageReference Include="Hangfire.Dashboard.Basic.Authentication" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Hangfire.MySql" Version="1.0.0" />
|
||||||
|
<!-- 覆盖 Hangfire 的旧传递依赖,修复 CVE-2024-21907。 -->
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="FreeSql" Version="3.5.311" />
|
<PackageReference Include="FreeSql" Version="3.5.311" />
|
||||||
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
|
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
|
||||||
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
|
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Debug",
|
"Default": "Debug",
|
||||||
"Override": {
|
"Override": {
|
||||||
|
"MAX.Framework.Security.TokenAuthenticationHandler": "Information",
|
||||||
"Microsoft": "Information",
|
"Microsoft": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"System": "Warning",
|
"System": "Warning",
|
||||||
|
|||||||
@@ -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,132 +7,133 @@ using System.Text;
|
|||||||
namespace Store.Users.Contracts.Dtos
|
namespace Store.Users.Contracts.Dtos
|
||||||
{
|
{
|
||||||
public class UserInfo
|
public class UserInfo
|
||||||
{/// <summary>
|
{
|
||||||
/// ID
|
/// <summary>
|
||||||
/// </summary>
|
/// ID
|
||||||
public int Id { get; set; }
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户昵称
|
/// 用户昵称
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string NickName { get; set; }
|
public string NickName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户名称(登录账户)
|
/// 用户名称(登录账户)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string UserName { get; set; }
|
public string UserName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 登录密码
|
/// 登录密码
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[System.Text.Json.Serialization.JsonIgnore]
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// card_id
|
/// card_id
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CardId { get; set; }
|
public string CardId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户分组,参见 <see cref="UserGroup"/>。
|
/// 用户分组,参见 <see cref="UserGroup"/>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public UserGroup GroupId { get; set; }
|
public UserGroup GroupId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 父账户用户名
|
/// 父账户用户名
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ParentUserName { get; set; }
|
public string ParentUserName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// oem编号,仅oem用户有值
|
/// oem编号,仅oem用户有值
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string OemId { get; set; }
|
public string OemId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 手机号
|
/// 手机号
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Mobile { get; set; }
|
public string Mobile { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 添加时间
|
/// 添加时间
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? AddTime { get; set; }
|
public DateTime? AddTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 修改时间
|
/// 修改时间
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? MdyTime { get; set; }
|
public DateTime? MdyTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 公司名称
|
/// 公司名称
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CompanyName { get; set; }
|
public string CompanyName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 备注
|
/// 备注
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Notes { get; set; }
|
public string Notes { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 超管等级,参见 <see cref="UserAdminLevel"/>。
|
/// 超管等级,参见 <see cref="UserAdminLevel"/>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public UserAdminLevel? SaLevel { get; set; }
|
public UserAdminLevel? SaLevel { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 账户权限
|
/// 账户权限
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Authority { get; set; }
|
public string Authority { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 子账号被禁止展示的功能页面
|
/// 子账号被禁止展示的功能页面
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ForbiddenPage { get; set; }
|
public string ForbiddenPage { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 账户积分,1积分=1元
|
/// 账户积分,1积分=1元
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal Points { get; set; }
|
public decimal Points { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 积分历史累计充值金额
|
/// 积分历史累计充值金额
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? RechargePointsTotal { get; set; }
|
public decimal? RechargePointsTotal { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 店铺每续费一个月需要的积分,单位元
|
/// 店铺每续费一个月需要的积分,单位元
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ShopPointsPerMonth { get; set; }
|
public int ShopPointsPerMonth { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 可用余额
|
/// 可用余额
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? Balance { get; set; }
|
public decimal? Balance { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 累计充值金额
|
/// 累计充值金额
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? RechargeAmountTotal { get; set; }
|
public decimal? RechargeAmountTotal { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 每单中转仓快递费用,单位元,两位小数
|
/// 每单中转仓快递费用,单位元,两位小数
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? PostagePerOrder { get; set; }
|
public decimal? PostagePerOrder { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 每单中转仓退货快递费用,单位元,两位小数
|
/// 每单中转仓退货快递费用,单位元,两位小数
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? RefundPostagePerOrder { get; set; }
|
public decimal? RefundPostagePerOrder { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 礼物每单额外收取费用,单位元,两位小数
|
/// 礼物每单额外收取费用,单位元,两位小数
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? GiftOemAmountPerOrder { get; set; }
|
public decimal? GiftOemAmountPerOrder { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 同步物流费用,单位元,两位小数
|
/// 同步物流费用,单位元,两位小数
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal? SyncLogisticsPerOrder { get; set; }
|
public decimal? SyncLogisticsPerOrder { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否主账户
|
/// 是否主账户
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class UserSearchRequest
|
|||||||
{
|
{
|
||||||
[Range(1, 1000000)]
|
[Range(1, 1000000)]
|
||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 1;
|
||||||
[Range(1, 200)]
|
[Range(1, 2000)]
|
||||||
public int PageSize { get; set; } = 50;
|
public int PageSize { get; set; } = 50;
|
||||||
[StringLength(100)]
|
[StringLength(100)]
|
||||||
public string? Keyword { get; set; }
|
public string? Keyword { get; set; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using MAX.Plugins;
|
using MAX.Plugins;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.MySql;
|
|
||||||
using HangfireBasicAuthenticationFilter;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -16,28 +14,17 @@ namespace Store.Modules.CloudProduct
|
|||||||
public override string Id => "CloudProduct";
|
public override string Id => "CloudProduct";
|
||||||
protected override void RegisterServices(IServiceCollection services)
|
protected override void RegisterServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
var configuration = Context.Configuration;
|
|
||||||
RequireConfiguration("Search:Url");
|
RequireConfiguration("Search:Url");
|
||||||
RequireConfiguration("Search:IndexName");
|
RequireConfiguration("Search:IndexName");
|
||||||
RequireConfiguration("Search:FavoritesIndexName");
|
RequireConfiguration("Search:FavoritesIndexName");
|
||||||
RequireConfiguration("RawData:Path");
|
RequireConfiguration("RawData:Path");
|
||||||
|
|
||||||
// ===== Hangfire 后台定时任务(商品分词同步) =====
|
// 业务连接独立于宿主的 Hangfire 存储。
|
||||||
var hangfireConnection = configuration.GetValue<string>("CloudProductConnection")
|
var connection = RequireConfiguration("CloudProductConnection");
|
||||||
?? throw new InvalidOperationException("缺少配置 CloudProductConnection,CloudProduct 模块的 Hangfire 无法启动。");
|
var connectionStringBuilder = new MySqlConnectionStringBuilder(connection)
|
||||||
|
{
|
||||||
// 存储中的建表/查询语句依赖用户变量;与业务仓储共用 FreeSql。
|
AllowUserVariables = true
|
||||||
var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection)
|
};
|
||||||
{
|
|
||||||
AllowUserVariables = true
|
|
||||||
};
|
|
||||||
|
|
||||||
services.AddHangfire((provider, config) => config
|
|
||||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
|
||||||
.UseSimpleAssemblyNameTypeSerializer()
|
|
||||||
.UseRecommendedSerializerSettings()
|
|
||||||
.UseStorage(new MySqlStorage(provider.GetRequiredKeyedService<IFreeSql>("CloudProductConnection"), new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
|
|
||||||
services.AddHangfireServer();
|
|
||||||
services.AddTransient<GoodsIndexJob>();
|
services.AddTransient<GoodsIndexJob>();
|
||||||
services.AddTransient<GoodsViolationIndexJob>();
|
services.AddTransient<GoodsViolationIndexJob>();
|
||||||
services.AddAuthorization(options => options.AddPolicy("CloudGoodsViolationWrite",
|
services.AddAuthorization(options => options.AddPolicy("CloudGoodsViolationWrite",
|
||||||
@@ -46,6 +33,12 @@ 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 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));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,18 +50,6 @@ namespace Store.Modules.CloudProduct
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var Configuration = Context.Configuration;
|
var Configuration = Context.Configuration;
|
||||||
// Hangfire 后台任务看板(默认地址 /hangfire)
|
|
||||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
|
||||||
{
|
|
||||||
Authorization = new[]
|
|
||||||
{
|
|
||||||
new HangfireCustomBasicAuthenticationFilter(){
|
|
||||||
User = Configuration.GetValue<string>("Hangfire:Dashboard:User") ?? "admin",
|
|
||||||
Pass = Configuration.GetValue<string>("Hangfire:Dashboard:Pass") ?? "1234560"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
|
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
|
||||||
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
|
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
|
||||||
|
|
||||||
@@ -83,8 +64,8 @@ namespace Store.Modules.CloudProduct
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配
|
// 定时任务注册失败时记录错误,数据库恢复后重启以重新注册。
|
||||||
Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
|
Console.WriteLine("[Hangfire] CloudProduct 定时任务注册失败: " + ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Store.Modules.CloudProduct.Contracts;
|
||||||
|
|
||||||
|
/// <summary>按图片阅读顺序返回文字和中文品牌名,无识别项返回空数组。</summary>
|
||||||
|
public interface IInspectionIntegration
|
||||||
|
{
|
||||||
|
Task<string[]> AnalyzeAsync(Stream image, string mediaType, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -10,13 +10,13 @@ using Store.Modules.CloudProduct.Entities;
|
|||||||
using Store.Modules.CloudProduct.ImportConverters;
|
using Store.Modules.CloudProduct.ImportConverters;
|
||||||
using Store.Modules.CloudProduct.Services;
|
using Store.Modules.CloudProduct.Services;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Store.Modules.CloudProduct.Controllers
|
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;
|
||||||
@@ -24,10 +24,12 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
private readonly IRawDataStorage _rawDataStorage;
|
private readonly IRawDataStorage _rawDataStorage;
|
||||||
private readonly GoodsIndexJob _goodsIndexJob;
|
private readonly GoodsIndexJob _goodsIndexJob;
|
||||||
private readonly GoodsViolationService _violations;
|
private readonly GoodsViolationService _violations;
|
||||||
|
private readonly ILogger<GoodsController> _logger;
|
||||||
|
|
||||||
public GoodsController(IGoodsService goodsService, IOpenSearchService searchService, IRawDataStorage rawDataStorage, GoodsIndexJob goodsIndexJob, GoodsViolationService violations)
|
public GoodsController(IGoodsService goodsService, IOpenSearchService searchService, IRawDataStorage rawDataStorage, GoodsIndexJob goodsIndexJob, GoodsViolationService violations, ILogger<GoodsController> logger)
|
||||||
{
|
{
|
||||||
_violations = violations;
|
_violations = violations;
|
||||||
|
_logger = logger;
|
||||||
_goodsService = goodsService;
|
_goodsService = goodsService;
|
||||||
_searchService = searchService;
|
_searchService = searchService;
|
||||||
_rawDataStorage = rawDataStorage;
|
_rawDataStorage = rawDataStorage;
|
||||||
@@ -46,6 +48,21 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
public Task<List<ReportGoodsViolationsResult>> ReportViolations([FromBody] ReportGoodsViolationItem[] items)
|
public Task<List<ReportGoodsViolationsResult>> ReportViolations([FromBody] ReportGoodsViolationItem[] items)
|
||||||
=> _violations.ReportAsync(items);
|
=> _violations.ReportAsync(items);
|
||||||
|
|
||||||
|
/// <summary>记录已完成的违规上报(1)、自动上货剔除(2)或店铺自动下架(3)。</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[EndpointSummary("批量上报违规日志,每次最多 1000 条")]
|
||||||
|
public async Task<ApiResult> RecordViolationLog([FromBody] RecordViolationLogRequest[] items)
|
||||||
|
{
|
||||||
|
await _violations.RecordLogsAsync(items);
|
||||||
|
return ApiResult.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按店铺编号数组查询违规日志,可筛选商品,按日志编号倒序翻页。</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[EndpointSummary("按商铺批量查询违规日志,每页最多 200 条")]
|
||||||
|
public Task<List<GoodsViolationLogEntity>> GetViolationLogs([FromBody] GetViolationLogsRequest request)
|
||||||
|
=> _violations.GetLogsAsync(request.ShopIds, request.GoodsId, request.BeforeId, request.Size);
|
||||||
|
|
||||||
/// <summary>批量解除商品违规,恢复正常商品索引;失败后由后台索引任务重试。</summary>
|
/// <summary>批量解除商品违规,恢复正常商品索引;失败后由后台索引任务重试。</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[EndpointSummary("解除商品违规并恢复索引")]
|
[EndpointSummary("解除商品违规并恢复索引")]
|
||||||
@@ -72,6 +89,22 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
{
|
{
|
||||||
await _goodsIndexJob.ProcessPendingAsync();
|
await _goodsIndexJob.ProcessPendingAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>循环处理数据库中待分词的正常商品,成功写入索引后标记 is_segmented=1。</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[EndpointSummary("重建所有待处理商品索引")]
|
||||||
|
public async Task<long> RebuildPendingIndex()
|
||||||
|
{
|
||||||
|
long count = 0;
|
||||||
|
int batchCount;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
batchCount = await _goodsService.IndexPendingGoodsAsync(20000);
|
||||||
|
count += batchCount;
|
||||||
|
} while (batchCount > 0);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 分页搜索商品,可按标题、价格、类目、销量和平台筛选。
|
/// 分页搜索商品,可按标题、价格、类目、销量和平台筛选。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -230,6 +263,35 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("/api/wx/data/import")]
|
||||||
|
[EndpointSummary("导入商品数据")]
|
||||||
|
public async Task<GoodsImportResult> ImportWXData([FromBody] string data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(data))
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(data));
|
||||||
|
}
|
||||||
|
var content = data;
|
||||||
|
var goods = GoodsConverterFactory.ConvertContent(content);
|
||||||
|
if (goods == null)
|
||||||
|
{
|
||||||
|
throw new AppException("未找到匹配的转换器,无法解析测试数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _goodsService.ImportGoodsAsync(new[]
|
||||||
|
{
|
||||||
|
new GoodsImportItem { Goods = goods, RawData = content }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "导入微信数据:");
|
||||||
|
throw new AppException("导入微信数据失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 导入接口:接收客户端批量上传的 JSON 文件,解析后入库并写入分词索引。
|
/// 导入接口:接收客户端批量上传的 JSON 文件,解析后入库并写入分词索引。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -368,7 +430,7 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
var node = JsonNode.Parse(raw);
|
var node = JsonNode.Parse(raw);
|
||||||
return node ?? throw new AppException("商品详情解析失败", ResultCode.BusinessError);
|
return node ?? throw new AppException("商品详情解析失败", ResultCode.BusinessError);
|
||||||
}
|
}
|
||||||
catch(Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
throw new AppException(ex.Message);
|
throw new AppException(ex.Message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -88,7 +96,7 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
/// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false。
|
/// 创建收藏分组:创建成功返回 true,同名分组已存在返回 false。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<bool> CreateGroup(CreateUserFavoriteGroupRequest request)
|
public async Task<bool> CreateGroup(UserFavoriteGroupRequest request)
|
||||||
{
|
{
|
||||||
if (request == null)
|
if (request == null)
|
||||||
{
|
{
|
||||||
@@ -113,7 +121,7 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询用户的收藏分组(含默认分组)及分组下收藏数量。
|
/// 查询用户的收藏分组(含默认分组)及分组下收藏数量。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpPost]
|
||||||
public async Task<List<UserFavoriteGroupDto>> GetGroups(string? userName = null)
|
public async Task<List<UserFavoriteGroupDto>> GetGroups(string? userName = null)
|
||||||
{
|
{
|
||||||
var resolved = ResolveUserName(userName);
|
var resolved = ResolveUserName(userName);
|
||||||
@@ -145,6 +153,41 @@ namespace Store.Modules.CloudProduct.Controllers
|
|||||||
|
|
||||||
return await _favoritesService.GetFavoriteStatusAsync(request);
|
return await _favoritesService.GetFavoriteStatusAsync(request);
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// 删除分组
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<bool> DelGroup(UserFavoriteGroupRequest request)
|
||||||
|
{
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.UserName = ResolveUserName(request.UserName);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||||
|
{
|
||||||
|
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _favoritesService.DelGroupAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>将已收藏商品移动到指定分组。</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<bool> SetFavoriteGroup(SetUserFavoriteGroupRequest request)
|
||||||
|
{
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.UserName = ResolveUserName(request.UserName);
|
||||||
|
return await _favoritesService.SetFavoriteGroupAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 检索用户收藏的商品(标题、销量、价格、类目、分组)。
|
/// 检索用户收藏的商品(标题、销量、价格、类目、分组)。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace Store.Modules.CloudProduct.Dtos
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建收藏分组请求
|
/// 创建收藏分组请求
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CreateUserFavoriteGroupRequest
|
public class UserFavoriteGroupRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户名(为空时取当前登录用户)
|
/// 用户名(为空时取当前登录用户)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ namespace Store.Modules.CloudProduct.Dtos;
|
|||||||
|
|
||||||
public class ViolationReasonDto
|
public class ViolationReasonDto
|
||||||
{
|
{
|
||||||
|
public uint Id { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public bool IsViolation { get; set; }
|
public bool IsViolation { get; set; }
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,8 @@ public class GoodsViolationStatus
|
|||||||
{
|
{
|
||||||
public long GoodsId { get; set; }
|
public long GoodsId { get; set; }
|
||||||
public bool IsViolation { get; set; }
|
public bool IsViolation { get; set; }
|
||||||
|
/// <summary>当前商品命中的违规原因,供客户端记录实际处理动作。</summary>
|
||||||
|
public int[] ReasonIds { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RemoveGoodsViolationsResult
|
public class RemoveGoodsViolationsResult
|
||||||
@@ -34,3 +37,30 @@ public class RemoveGoodsViolationsResult
|
|||||||
/// <summary>商品不存在、已删除或恢复期间再次被上报违规的编号。</summary>
|
/// <summary>商品不存在、已删除或恢复期间再次被上报违规的编号。</summary>
|
||||||
public long[] SkippedGoodsIds { get; set; } = [];
|
public long[] SkippedGoodsIds { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>日志记录的实际业务动作,与商品违规原因分别保存。</summary>
|
||||||
|
public enum ViolationLogAction
|
||||||
|
{
|
||||||
|
Report = 1,
|
||||||
|
AutoListingExcluded = 2,
|
||||||
|
AutoOffShelf = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>客户端在店铺动作完成后提交一条日志。</summary>
|
||||||
|
public sealed class RecordViolationLogRequest
|
||||||
|
{
|
||||||
|
public string ShopId { get; set; } = string.Empty;
|
||||||
|
public long GoodsId { get; set; }
|
||||||
|
/// <summary>违规类型在 violation_reason 中的编号。</summary>
|
||||||
|
public int ReasonId { get; set; }
|
||||||
|
public ViolationLogAction Action { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按商铺编号查询违规日志;翻页时传入上一页最后一条日志的编号。</summary>
|
||||||
|
public sealed class GetViolationLogsRequest
|
||||||
|
{
|
||||||
|
public string[] ShopIds { get; set; } = [];
|
||||||
|
public long? GoodsId { get; set; }
|
||||||
|
public long BeforeId { get; set; }
|
||||||
|
public int Size { get; set; } = 50;
|
||||||
|
}
|
||||||
@@ -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,15 @@
|
|||||||
|
namespace Store.Modules.CloudProduct.Dtos
|
||||||
|
{
|
||||||
|
/// <summary>将用户已收藏的商品移动到指定分组。</summary>
|
||||||
|
public class SetUserFavoriteGroupRequest
|
||||||
|
{
|
||||||
|
/// <summary>用户名称,为空时由 API 使用当前登录用户。</summary>
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>商品编号集合。</summary>
|
||||||
|
public List<long> GoodsIds { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>目标分组 ID。</summary>
|
||||||
|
public int GroupId { 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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,6 @@ namespace Store.Modules.CloudProduct.Dtos
|
|||||||
public string GroupName { get; set; } = string.Empty;
|
public string GroupName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>该分组下的收藏数量</summary>
|
/// <summary>该分组下的收藏数量</summary>
|
||||||
public long FavoriteCount { get; set; }
|
//public long FavoriteCount { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
using FreeSql.DataAnnotations;
|
||||||
|
using Store.Modules.CloudProduct.Dtos;
|
||||||
|
|
||||||
|
namespace Store.Modules.CloudProduct.Entities;
|
||||||
|
|
||||||
|
/// <summary>不可变的违规事件历史,解除商品违规时保留。</summary>
|
||||||
|
[Table(Name = "goods_violation_log")]
|
||||||
|
public class GoodsViolationLogEntity
|
||||||
|
{
|
||||||
|
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||||
|
public long Id { get; set; }
|
||||||
|
[Column(Name = "shop_id", StringLength = 64)]
|
||||||
|
public string? ShopId { get; set; }
|
||||||
|
[Column(Name = "goods_id")]
|
||||||
|
public long GoodsId { get; set; }
|
||||||
|
/// <summary>对应 violation_reason.id。</summary>
|
||||||
|
[Column(Name = "reason_id")]
|
||||||
|
public int ReasonId { get; set; }
|
||||||
|
[Column(Name = "action", MapType = typeof(int))]
|
||||||
|
public ViolationLogAction Action { get; set; }
|
||||||
|
[Column(Name = "created_time")]
|
||||||
|
public DateTime CreatedTime { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using FreeSql.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>从 UpdatedAt 起算的缓存有效小时数,命中时不续期。</summary>
|
||||||
|
public int ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>每轮成功识别后更新有效期:空结果逐级延长,有内容则重置级别。</summary>
|
||||||
|
public void RecordExpiration(bool isEmpty, DateTime utcNow)
|
||||||
|
{
|
||||||
|
// 新记录和上次有内容的记录,首次为空时均从 24 小时起算。
|
||||||
|
ExpiresAt = isEmpty ? ExpiresAt switch
|
||||||
|
{
|
||||||
|
24 => 48,
|
||||||
|
48 => 96,
|
||||||
|
96 => 192,
|
||||||
|
192 or 384 => 384,
|
||||||
|
_ => 24
|
||||||
|
} : 720;
|
||||||
|
UpdatedAt = utcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>仅复用有效期内的数组,旧对象格式需要重新识别。</summary>
|
||||||
|
public string[]? ReadFreshResult(DateTime utcNow)
|
||||||
|
{
|
||||||
|
if (utcNow >= UpdatedAt.AddHours(ExpiresAt) || !RecognitionResult.TrimStart().StartsWith('['))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<string[]>(RecognitionResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using Store.Framework.Models;
|
||||||
using Store.Modules.CloudProduct.Entities;
|
using Store.Modules.CloudProduct.Entities;
|
||||||
using Store.Modules.CloudProduct.Enums;
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace Store.Modules.CloudProduct.ImportConverters
|
namespace Store.Modules.CloudProduct.ImportConverters
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Store.CloudProduct.ImportConverters;
|
||||||
using Store.Modules.CloudProduct.Entities;
|
using Store.Modules.CloudProduct.Entities;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -12,6 +13,7 @@ namespace Store.Modules.CloudProduct.ImportConverters
|
|||||||
private static readonly List<IGoodsConverter> _converters = new List<IGoodsConverter>
|
private static readonly List<IGoodsConverter> _converters = new List<IGoodsConverter>
|
||||||
{
|
{
|
||||||
new PddGoodsConverter(),
|
new PddGoodsConverter(),
|
||||||
|
new WXGoodsConverter(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using Store.Framework.Models;
|
||||||
using Store.Modules.CloudProduct.Entities;
|
using Store.Modules.CloudProduct.Entities;
|
||||||
using Store.Modules.CloudProduct.Enums;
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace Store.Modules.CloudProduct.ImportConverters
|
namespace Store.Modules.CloudProduct.ImportConverters
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
using MAX.Core.Extensions;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Org.BouncyCastle.Asn1.Cms;
|
||||||
|
using Store.Framework.Models;
|
||||||
|
using Store.Modules.CloudProduct.Entities;
|
||||||
|
using Store.Modules.CloudProduct.ImportConverters;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.ServerSentEvents;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Store.CloudProduct.ImportConverters
|
||||||
|
{
|
||||||
|
internal class WXGoodsConverter : GoodsConverter
|
||||||
|
{
|
||||||
|
public override string ConverterName => nameof(WXGoodsConverter);
|
||||||
|
|
||||||
|
public override PlatformType Platform => PlatformType.WX;
|
||||||
|
private JObject _jsonRoot;
|
||||||
|
private JObject _productNode; // detail.product
|
||||||
|
private JObject _infoNode; // detail.product.info
|
||||||
|
private JArray _skuList; // detail.product.info.sku_list.sku_info(价格兜底)
|
||||||
|
private JArray _productSkus; // detail.product.product_skus(完整SKU)
|
||||||
|
private JArray _saleAttrs; // detail.product.info.product_sale_attrs
|
||||||
|
|
||||||
|
protected override bool IsMatchCore()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_jsonRoot = JsonConvert.DeserializeObject<JObject>(RawData);
|
||||||
|
if (_jsonRoot == null) return false;
|
||||||
|
|
||||||
|
var detail = _jsonRoot.SelectToken("$.detail") as JObject;
|
||||||
|
if (detail == null) return false;
|
||||||
|
|
||||||
|
var product = detail.SelectToken("$.product") as JObject;
|
||||||
|
if (product == null) return false;
|
||||||
|
|
||||||
|
var productKey = product.SelectToken("$.product_key") as JObject;
|
||||||
|
if (productKey == null) return false;
|
||||||
|
|
||||||
|
var productId = productKey.SelectToken("$.product_id")?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(productId)) return false;
|
||||||
|
|
||||||
|
var info = product.SelectToken("$.info") as JObject;
|
||||||
|
if (info == null) return false;
|
||||||
|
|
||||||
|
var title = info.SelectToken("$.title")?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(title)) return false;
|
||||||
|
|
||||||
|
var productSkus = product.SelectToken("$.product_skus") as JArray;
|
||||||
|
var skuList = info.SelectToken("$.sku_list.sku_info") as JArray;
|
||||||
|
|
||||||
|
if ((productSkus == null || productSkus.Count == 0) &&
|
||||||
|
(skuList == null || skuList.Count == 0))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_productNode = product;
|
||||||
|
_infoNode = info;
|
||||||
|
_productSkus = productSkus;
|
||||||
|
_skuList = skuList;
|
||||||
|
_saleAttrs = info.SelectToken("$.product_sale_attrs") as JArray;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected override GoodsEntity ConvertCore()
|
||||||
|
{
|
||||||
|
var product = ConverterProvider();
|
||||||
|
var item = new
|
||||||
|
GoodsEntity
|
||||||
|
{
|
||||||
|
Platform = (byte)PlatformType.WX,
|
||||||
|
GoodsId = product.ProductId.ToLong(0),
|
||||||
|
Title = product.Title ?? string.Empty,
|
||||||
|
Description = product.Description,
|
||||||
|
ThumbUrl = product.ThumbUrl,
|
||||||
|
SoldQuantity = GetTotalSold(),
|
||||||
|
Status = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.MinPrice = ((long)product.Skus.Min(a=>a.Price));
|
||||||
|
item.MaxPrice = ((long)product.Skus.Max(a => a.Price));
|
||||||
|
item.CatId1 = product.CategoryIds.Count > 0 ? product.CategoryIds[0] : (int?)null;
|
||||||
|
item.CatId2 = product.CategoryIds.Count > 1 ? product.CategoryIds[1] : (int?)null;
|
||||||
|
item.CatId3 = product.CategoryIds.Count > 2 ? product.CategoryIds[2] : (int?)null;
|
||||||
|
item.CatId4 = product.CategoryIds.Count > 3 ? product.CategoryIds[3] : (int?)null;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
private Product ConverterProvider()
|
||||||
|
{
|
||||||
|
var product = new Product
|
||||||
|
{
|
||||||
|
Platform = Platform,
|
||||||
|
ProductId = ParseGoodsId()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 基本信息
|
||||||
|
product.Title = GetStringValue(_infoNode, "title");
|
||||||
|
product.Description = GetStringValue(_infoNode, "short_title");
|
||||||
|
if (string.IsNullOrEmpty(product.Description))
|
||||||
|
product.Description = GetStringValue(_infoNode, "ai_gen_short_title");
|
||||||
|
|
||||||
|
// 2. 品牌(新模型无 BrandId,只有 Brand 名称)
|
||||||
|
var brandId = GetLongValue(_infoNode, "brand_id");
|
||||||
|
product.Brand = brandId > 0 ? brandId.ToString() : string.Empty;
|
||||||
|
|
||||||
|
// 3. 类目
|
||||||
|
product.CategoryId = GetCategoryId();
|
||||||
|
product.CategoryIds = GetCategoryPath();
|
||||||
|
|
||||||
|
// 4. 主图
|
||||||
|
product.MainImages = GetMainImages();
|
||||||
|
|
||||||
|
// 5. 详情图
|
||||||
|
product.DetailImages = GetDetailImages();
|
||||||
|
|
||||||
|
// 6. 属性
|
||||||
|
product.Attributes = GetProperties();
|
||||||
|
|
||||||
|
// 7. 规格 + SKU
|
||||||
|
var skuResult = GetSkusAndSpecs();
|
||||||
|
product.Skus = skuResult.Item1;
|
||||||
|
product.Specs = skuResult.Item2;
|
||||||
|
|
||||||
|
// 8. 媒体与视频(新模型只有单个 Video)
|
||||||
|
product.ThumbUrl = product.MainImages.FirstOrDefault() ?? string.Empty;
|
||||||
|
product.Video = GetVideoUrls().FirstOrDefault() ?? string.Empty;
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 核心字段解析
|
||||||
|
|
||||||
|
private string ParseGoodsId()
|
||||||
|
{
|
||||||
|
var productId = _productNode.SelectToken("$.product_key.product_id")?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(productId))
|
||||||
|
return productId;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetCategoryId()
|
||||||
|
{
|
||||||
|
var category = _infoNode["category"] as JArray;
|
||||||
|
if (category == null || category.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var lastCat = category.Last as JObject;
|
||||||
|
return lastCat?["cat_id"]?.Value<int>() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<int> GetCategoryPath()
|
||||||
|
{
|
||||||
|
var result = new List<int>();
|
||||||
|
var category = _infoNode["category"] as JArray;
|
||||||
|
if (category == null) return result;
|
||||||
|
|
||||||
|
foreach (var cat in category)
|
||||||
|
{
|
||||||
|
var catId = cat["cat_id"]?.Value<int>();
|
||||||
|
if (catId.HasValue && catId.Value > 0)
|
||||||
|
result.Add(catId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> GetMainImages()
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
|
||||||
|
var headImg = _infoNode["head_img"] as JArray;
|
||||||
|
if (headImg != null && headImg.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var img in headImg)
|
||||||
|
{
|
||||||
|
var url = img?.Value<string>();
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
result.Add(url);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headImgInfos = _infoNode["head_img_infos"] as JArray;
|
||||||
|
if (headImgInfos != null)
|
||||||
|
{
|
||||||
|
foreach (var img in headImgInfos)
|
||||||
|
{
|
||||||
|
var url = img["img_url"]?.Value<string>();
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
result.Add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Distinct().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> GetDetailImages()
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var detail = _infoNode["detail"] as JObject;
|
||||||
|
if (detail == null) return result;
|
||||||
|
|
||||||
|
var detailImg = detail["detail_img"] as JArray;
|
||||||
|
if (detailImg == null) return result;
|
||||||
|
|
||||||
|
foreach (var img in detailImg)
|
||||||
|
{
|
||||||
|
var url = img?.Value<string>();
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
result.Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 属性转换为新模型结构:多个同名key的value合并到 Values 列表
|
||||||
|
/// </summary>
|
||||||
|
private List<ProductAttribute> GetProperties()
|
||||||
|
{
|
||||||
|
var result = new List<ProductAttribute>();
|
||||||
|
var param = _infoNode["param"] as JArray;
|
||||||
|
if (param == null) return result;
|
||||||
|
|
||||||
|
foreach (var item in param)
|
||||||
|
{
|
||||||
|
var categorys = item["categorys"] as JArray;
|
||||||
|
if (categorys == null || categorys.Count < 2) continue;
|
||||||
|
|
||||||
|
var key = categorys[0]?["name"]?.Value<string>();
|
||||||
|
var value = categorys[1]?["name"]?.Value<string>();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) continue;
|
||||||
|
|
||||||
|
var existing = result.FirstOrDefault(p => p.Name == key);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
result.Add(new ProductAttribute
|
||||||
|
{
|
||||||
|
Name = key,
|
||||||
|
Values = new List<string> { value }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (!existing.Values.Contains(value))
|
||||||
|
{
|
||||||
|
existing.Values.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> GetVideoUrls()
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var headVideoInfo = _infoNode["head_video_info"] as JArray;
|
||||||
|
if (headVideoInfo == null) return result;
|
||||||
|
|
||||||
|
foreach (var video in headVideoInfo)
|
||||||
|
{
|
||||||
|
var url = video["video_url"]?.Value<string>();
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
result.Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region SKU和规格解析
|
||||||
|
|
||||||
|
private Tuple<List<ProductSku>, List<ProductSpec>> GetSkusAndSpecs()
|
||||||
|
{
|
||||||
|
var skuList = new List<ProductSku>();
|
||||||
|
var specList = new List<ProductSpec>();
|
||||||
|
|
||||||
|
var priceFallbackDict = BuildPriceFallbackDict();
|
||||||
|
var attrFallbackDict = BuildAttrFallbackDict();
|
||||||
|
|
||||||
|
if (_productSkus != null && _productSkus.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var sku in _productSkus)
|
||||||
|
{
|
||||||
|
var skuObj = sku as JObject;
|
||||||
|
if (skuObj == null) continue;
|
||||||
|
|
||||||
|
var skuItem = ParseProductSku(skuObj, priceFallbackDict, attrFallbackDict);
|
||||||
|
if (skuItem != null)
|
||||||
|
skuList.Add(skuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_skuList != null && _skuList.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var sku in _skuList)
|
||||||
|
{
|
||||||
|
var skuObj = sku as JObject;
|
||||||
|
if (skuObj == null) continue;
|
||||||
|
|
||||||
|
var skuItem = ParseSkuInfoOnly(skuObj, attrFallbackDict);
|
||||||
|
if (skuItem != null)
|
||||||
|
skuList.Add(skuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildSpecList(specList, skuList);
|
||||||
|
|
||||||
|
return new Tuple<List<ProductSku>, List<ProductSpec>>(skuList, specList);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, decimal> BuildPriceFallbackDict()
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, decimal>();
|
||||||
|
if (_skuList == null) return dict;
|
||||||
|
|
||||||
|
foreach (var sku in _skuList)
|
||||||
|
{
|
||||||
|
var skuObj = sku as JObject;
|
||||||
|
if (skuObj == null) continue;
|
||||||
|
|
||||||
|
var skuId = skuObj["sku_id"]?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(skuId)) continue;
|
||||||
|
|
||||||
|
var estimatePrice = skuObj["estimate_price"]?.Value<decimal>() ?? 0;
|
||||||
|
var price = skuObj["price"]?.Value<decimal>() ?? 0;
|
||||||
|
dict[skuId] = estimatePrice > 0 ? estimatePrice : price;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// skuId -> 结构化的规格列表
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, List<ProductSkuSpec>> BuildAttrFallbackDict()
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, List<ProductSkuSpec>>();
|
||||||
|
if (_saleAttrs == null) return dict;
|
||||||
|
|
||||||
|
foreach (var attr in _saleAttrs)
|
||||||
|
{
|
||||||
|
var key = attr["key"]?.Value<string>() ?? string.Empty;
|
||||||
|
var items = attr["items"] as JArray;
|
||||||
|
if (items == null) continue;
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var value = item["value_v2"]?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
value = item["value"]?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(value)) continue;
|
||||||
|
|
||||||
|
var skuIds = item["sku_ids"] as JArray;
|
||||||
|
if (skuIds == null) continue;
|
||||||
|
|
||||||
|
foreach (var skuIdToken in skuIds)
|
||||||
|
{
|
||||||
|
var id = skuIdToken?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(id)) continue;
|
||||||
|
|
||||||
|
if (!dict.ContainsKey(id))
|
||||||
|
dict[id] = new List<ProductSkuSpec>();
|
||||||
|
|
||||||
|
dict[id].Add(new ProductSkuSpec { Name = key, Value = value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductSku ParseProductSku(
|
||||||
|
JObject skuObj,
|
||||||
|
Dictionary<string, decimal> priceFallbackDict,
|
||||||
|
Dictionary<string, List<ProductSkuSpec>> attrFallbackDict)
|
||||||
|
{
|
||||||
|
var skuKey = skuObj["sku_key"] as JObject;
|
||||||
|
var skuInfo = skuObj["product_sku_info"] as JObject;
|
||||||
|
if (skuKey == null || skuInfo == null) return null;
|
||||||
|
|
||||||
|
var skuId = skuKey["sku_id"]?.Value<string>() ?? string.Empty;
|
||||||
|
var outSkuId = skuKey["out_sku_id"]?.Value<string>() ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(skuId)) return null;
|
||||||
|
|
||||||
|
var skuSpecs = ParseSkuSpecs(skuInfo, skuId, attrFallbackDict);
|
||||||
|
|
||||||
|
var salePrice = skuInfo["sale_price"]?.Value<decimal>() ?? 0;
|
||||||
|
if (salePrice <= 0 && priceFallbackDict.TryGetValue(skuId, out var fallbackPrice))
|
||||||
|
salePrice = fallbackPrice;
|
||||||
|
|
||||||
|
var stockNum = ParseStockNum(skuInfo);
|
||||||
|
var thumbImg = skuInfo["thumb_img"]?.Value<string>() ?? string.Empty;
|
||||||
|
|
||||||
|
return new ProductSku
|
||||||
|
{
|
||||||
|
SkuId = !string.IsNullOrEmpty(outSkuId) ? outSkuId : skuId,
|
||||||
|
Price = salePrice / 100,
|
||||||
|
Stock = stockNum,
|
||||||
|
ImageUrl = thumbImg,
|
||||||
|
Specs = skuSpecs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductSku ParseSkuInfoOnly(
|
||||||
|
JObject skuObj,
|
||||||
|
Dictionary<string, List<ProductSkuSpec>> attrFallbackDict)
|
||||||
|
{
|
||||||
|
var skuId = skuObj["sku_id"]?.Value<string>() ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(skuId)) return null;
|
||||||
|
|
||||||
|
var skuSpecs = attrFallbackDict.TryGetValue(skuId, out var specs)
|
||||||
|
? specs
|
||||||
|
: new List<ProductSkuSpec>();
|
||||||
|
|
||||||
|
var estimatePrice = skuObj["estimate_price"]?.Value<decimal>() ?? 0;
|
||||||
|
var price = skuObj["price"]?.Value<decimal>() ?? 0;
|
||||||
|
var actualPrice = estimatePrice > 0 ? estimatePrice : price;
|
||||||
|
|
||||||
|
return new ProductSku
|
||||||
|
{
|
||||||
|
SkuId = skuId,
|
||||||
|
Price = actualPrice / 100,
|
||||||
|
Stock = 0,
|
||||||
|
ImageUrl = string.Empty,
|
||||||
|
Specs = skuSpecs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析SKU的结构化规格列表
|
||||||
|
/// </summary>
|
||||||
|
private List<ProductSkuSpec> ParseSkuSpecs(
|
||||||
|
JObject skuInfo,
|
||||||
|
string skuId,
|
||||||
|
Dictionary<string, List<ProductSkuSpec>> attrFallbackDict)
|
||||||
|
{
|
||||||
|
var specs = new List<ProductSkuSpec>();
|
||||||
|
var saleParams = skuInfo["sale_param"] as JArray;
|
||||||
|
|
||||||
|
if (saleParams != null)
|
||||||
|
{
|
||||||
|
foreach (var param in saleParams)
|
||||||
|
{
|
||||||
|
var paramV2 = param["param_v2"] as JObject;
|
||||||
|
if (paramV2 != null)
|
||||||
|
{
|
||||||
|
var attrName = paramV2["attr_name"]?.Value<string>() ?? string.Empty;
|
||||||
|
var attrValue = paramV2["attr_value_name"]?.Value<string>() ?? string.Empty;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(attrName) && !string.IsNullOrEmpty(attrValue))
|
||||||
|
{
|
||||||
|
specs.Add(new ProductSkuSpec { Name = attrName, Value = attrValue });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var categorys = param["categorys"] as JArray;
|
||||||
|
if (categorys != null && categorys.Count >= 2)
|
||||||
|
{
|
||||||
|
var attrName = categorys[0]?["name"]?.Value<string>() ?? string.Empty;
|
||||||
|
var attrValue = categorys[1]?["name"]?.Value<string>() ?? string.Empty;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(attrName) && !string.IsNullOrEmpty(attrValue))
|
||||||
|
{
|
||||||
|
specs.Add(new ProductSkuSpec { Name = attrName, Value = attrValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specs.Count == 0 && attrFallbackDict.TryGetValue(skuId, out var fallbackSpecs))
|
||||||
|
{
|
||||||
|
specs.AddRange(fallbackSpecs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ParseStockNum(JObject skuInfo)
|
||||||
|
{
|
||||||
|
var stockInfo = skuInfo["stock_info"] as JObject;
|
||||||
|
if (stockInfo == null) return 0;
|
||||||
|
|
||||||
|
var stockToken = stockInfo["stock_num"];
|
||||||
|
if (stockToken == null) return 0;
|
||||||
|
|
||||||
|
if (stockToken.Type == JTokenType.String)
|
||||||
|
return int.TryParse(stockToken.Value<string>(), out var result) ? result : 0;
|
||||||
|
|
||||||
|
return stockToken.Value<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构建聚合后的规格定义列表
|
||||||
|
/// </summary>
|
||||||
|
private void BuildSpecList(List<ProductSpec> specList, List<ProductSku> skus)
|
||||||
|
{
|
||||||
|
var specDict = new Dictionary<string, HashSet<string>>();
|
||||||
|
|
||||||
|
// 优先从 product_sale_attrs
|
||||||
|
if (_saleAttrs != null && _saleAttrs.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var attr in _saleAttrs)
|
||||||
|
{
|
||||||
|
var key = attr["key"]?.Value<string>() ?? string.Empty;
|
||||||
|
var items = attr["items"] as JArray;
|
||||||
|
if (items == null) continue;
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var value = item["value_v2"]?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
value = item["value"]?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(value)) continue;
|
||||||
|
|
||||||
|
if (!specDict.ContainsKey(key))
|
||||||
|
specDict[key] = new HashSet<string>();
|
||||||
|
specDict[key].Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:从SKU的结构化规格解析
|
||||||
|
if (specDict.Count == 0 && skus.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var sku in skus)
|
||||||
|
{
|
||||||
|
foreach (var s in sku.Specs)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s.Name)) continue;
|
||||||
|
if (!specDict.ContainsKey(s.Name))
|
||||||
|
specDict[s.Name] = new HashSet<string>();
|
||||||
|
specDict[s.Name].Add(s.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var spec in specDict)
|
||||||
|
{
|
||||||
|
specList.Add(new ProductSpec
|
||||||
|
{
|
||||||
|
Name = spec.Key,
|
||||||
|
Values = spec.Value.ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private int GetTotalSold()
|
||||||
|
{
|
||||||
|
var stockInfo = _infoNode["total_sold_num"] as JToken;
|
||||||
|
if (stockInfo == null) return 0;
|
||||||
|
return stockInfo.Value<int>();
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 辅助方法
|
||||||
|
|
||||||
|
private string GetStringValue(JObject obj, string path)
|
||||||
|
=> obj?.SelectToken(path)?.Value<string>() ?? string.Empty;
|
||||||
|
|
||||||
|
private long GetLongValue(JObject obj, string path)
|
||||||
|
{
|
||||||
|
var token = obj?.SelectToken(path);
|
||||||
|
if (token == null) return 0;
|
||||||
|
|
||||||
|
if (token.Type == JTokenType.String)
|
||||||
|
return long.TryParse(token.Value<string>(), out var result) ? result : 0;
|
||||||
|
|
||||||
|
return token.Value<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 商品图片识别
|
||||||
|
|
||||||
|
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}` | 无数据 |
|
||||||
|
|
||||||
|
客户端根据识别结果判断商品是否安全,再上报已收藏商品的结论。最新结论可从收藏检索接口读取。
|
||||||
|
|
||||||
|
## 配置与部署
|
||||||
|
|
||||||
|
图片识别缓存使用插件的 `CloudProductConnection` 数据库连接。将旧插件配置中的 `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 -- --migrate-cache-expiration`,再启动新版本。命令读取 `CloudProductConnection`,可通过 `Plugins__CloudProduct__CloudProductConnection` 指定目标库。数据库的 `ExpiresAt` 保存整数小时数,连续空结果的下一档由上一次的小时数决定。迁移保留原更新时间,将旧记录回填为对应小时数。服务器 UTC 时间达到 `UpdatedAt.AddHours(ExpiresAt)` 时缓存过期。空结果从 1 天开始,连续为空时逐级延长至 2、4、8、16 天并封顶。有内容的结果缓存 30 天,之后首次为空时重新从 1 天开始。
|
||||||
|
|
||||||
|
旧库升级命令 `dotnet run --project Tests/ProductInspection.Checks -- --initialize` 会更名旧结果列,移除 DetectionText、LogoBrands 列及四张旧任务相关表,并同步缓存表结构。执行前请备份数据库,先归档或清理仍使用 SHA-256 键的缓存。
|
||||||
@@ -13,13 +13,19 @@ public class GoodsViolationRepository(
|
|||||||
public Task<List<ViolationReasonDto>> GetReasonsAsync() => Orm.Select<ViolationReasonEntity>()
|
public Task<List<ViolationReasonDto>> GetReasonsAsync() => Orm.Select<ViolationReasonEntity>()
|
||||||
.OrderBy(x => x.Id).ToListAsync(x => new ViolationReasonDto
|
.OrderBy(x => x.Id).ToListAsync(x => new ViolationReasonDto
|
||||||
{
|
{
|
||||||
Name = x.Name,
|
Id = x.Id, Name = x.Name,
|
||||||
IsViolation = x.IsViolation
|
IsViolation = x.IsViolation
|
||||||
});
|
});
|
||||||
|
|
||||||
public Task<List<long>> GetViolationIdsAsync(long[] ids) => Select
|
public Task<List<long>> GetViolationIdsAsync(long[] ids) => Select
|
||||||
.Where(x => ids.Contains(x.GoodsId)).Distinct().ToListAsync(x => x.GoodsId);
|
.Where(x => ids.Contains(x.GoodsId)).Distinct().ToListAsync(x => x.GoodsId);
|
||||||
|
|
||||||
|
public async Task<ILookup<long, int>> GetReasonIdsByGoodsAsync(long[] ids)
|
||||||
|
{
|
||||||
|
var rows = await Select.Where(x => ids.Contains(x.GoodsId)).ToListAsync(x => new { x.GoodsId, x.ReasonId });
|
||||||
|
return rows.ToLookup(x => x.GoodsId, x => checked((int)x.ReasonId));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<long>> GetOffShelfGoodsIdsAsync(long[] ids)
|
public async Task<List<long>> GetOffShelfGoodsIdsAsync(long[] ids)
|
||||||
{
|
{
|
||||||
var records = await Select.Where(x => ids.Contains(x.GoodsId)).ToListAsync();
|
var records = await Select.Where(x => ids.Contains(x.GoodsId)).ToListAsync();
|
||||||
@@ -54,25 +60,25 @@ public class GoodsViolationRepository(
|
|||||||
using var unit = Orm.CreateUnitOfWork();
|
using var unit = Orm.CreateUnitOfWork();
|
||||||
var repository = unit.GetRepository<GoodsViolationEntity>();
|
var repository = unit.GetRepository<GoodsViolationEntity>();
|
||||||
var reasonRepository = unit.GetRepository<ViolationReasonEntity>();
|
var reasonRepository = unit.GetRepository<ViolationReasonEntity>();
|
||||||
var reasonIds = new Dictionary<string, uint>(StringComparer.Ordinal);
|
var dictionary = new Dictionary<string, ViolationReasonEntity>(StringComparer.Ordinal);
|
||||||
foreach (var name in items.Values.SelectMany(x => x).Distinct(StringComparer.Ordinal))
|
foreach (var name in items.Values.SelectMany(x => x).Distinct(StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
// 让数据库按字典表的排序规则匹配名称,与唯一索引保持一致。
|
// 让数据库按字典表的排序规则匹配名称,与唯一索引保持一致。
|
||||||
var reason = await reasonRepository.Select.Where(x => x.Name == name).FirstAsync();
|
var reason = await reasonRepository.Select.Where(x => x.Name == name).FirstAsync();
|
||||||
// 新类型只收录到字典,绝不因首次上报自动启用商品封禁。
|
// 新类型只收录到字典,绝不因首次上报自动启用商品封禁。
|
||||||
reason ??= await reasonRepository.InsertAsync(new ViolationReasonEntity { Name = name, IsViolation = false });
|
reason ??= await reasonRepository.InsertAsync(new ViolationReasonEntity { Name = name, IsViolation = false });
|
||||||
// 数据库排序规则可能忽略部分字符差异;判定时仍要求名称完整相等。
|
dictionary.Add(name, reason);
|
||||||
if (reason.IsViolation && string.Equals(reason.Name, name, StringComparison.Ordinal))
|
|
||||||
reasonIds.Add(name, reason.Id);
|
|
||||||
}
|
}
|
||||||
var ids = items.Keys.ToArray();
|
var ids = items.Keys.ToArray();
|
||||||
var existing = (await repository.Select.Where(x => ids.Contains(x.GoodsId))
|
var existing = (await repository.Select.Where(x => ids.Contains(x.GoodsId))
|
||||||
.ToListAsync()).ToLookup(x => x.GoodsId);
|
.ToListAsync()).ToLookup(x => x.GoodsId);
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.Now;
|
||||||
var recordedIds = new List<long>();
|
var recordedIds = new List<long>();
|
||||||
foreach (var (goodsId, reasons) in items)
|
foreach (var (goodsId, reasons) in items)
|
||||||
{
|
{
|
||||||
var accepted = reasons.Where(reasonIds.ContainsKey).Select(x => reasonIds[x]).Distinct().ToArray();
|
// 判定要求名称完整匹配。
|
||||||
|
var accepted = reasons.Where(name => dictionary[name].IsViolation && dictionary[name].Name == name)
|
||||||
|
.Select(name => dictionary[name].Id).Distinct().ToArray();
|
||||||
// 忽略原因仅记录字典,不新增或改变该商品已有的违规状态。
|
// 忽略原因仅记录字典,不新增或改变该商品已有的违规状态。
|
||||||
if (accepted.Length == 0) continue;
|
if (accepted.Length == 0) continue;
|
||||||
var rows = existing[goodsId].ToArray();
|
var rows = existing[goodsId].ToArray();
|
||||||
@@ -101,9 +107,39 @@ public class GoodsViolationRepository(
|
|||||||
return recordedIds.ToArray();
|
return recordedIds.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<long> CountReasonsAsync(uint[] ids) => Orm.Select<ViolationReasonEntity>()
|
||||||
|
.Where(x => ids.Contains(x.Id)).CountAsync();
|
||||||
|
|
||||||
|
public async Task InsertLogsAsync(GoodsViolationLogEntity[] logs)
|
||||||
|
{
|
||||||
|
using var unit = Orm.CreateUnitOfWork();
|
||||||
|
unit.GetOrBeginTransaction();
|
||||||
|
foreach (var log in logs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await unit.Orm.Insert(log).ExecuteAffrowsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex.GetBaseException() is MySql.Data.MySqlClient.MySqlException { Number: 1062 } duplicate
|
||||||
|
&& (duplicate.Message.EndsWith(".ux_violation_log_event'", StringComparison.Ordinal)
|
||||||
|
|| duplicate.Message.EndsWith("'ux_violation_log_event'", StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
// 事件唯一键已存在即视为上报成功,保留首次记录,继续写入本批其他事件。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游标按自增主键倒序翻页,同一时间的事件也有确定顺序。
|
||||||
|
public Task<List<GoodsViolationLogEntity>> GetLogsAsync(string[] shopIds, long? goodsId, long beforeId, int size)
|
||||||
|
=> Orm.Select<GoodsViolationLogEntity>().Where(x => shopIds.Contains(x.ShopId!))
|
||||||
|
.WhereIf(goodsId.HasValue, x => x.GoodsId == goodsId)
|
||||||
|
.WhereIf(beforeId > 0, x => x.Id < beforeId).OrderByDescending(x => x.Id).Limit(size).ToListAsync();
|
||||||
|
|
||||||
public Task<int> MarkDeletedAsync(long[] ids) => Orm.Update<GoodsViolationEntity>()
|
public Task<int> MarkDeletedAsync(long[] ids) => Orm.Update<GoodsViolationEntity>()
|
||||||
.Set(x => x.IsIndexDeleted, true)
|
.Set(x => x.IsIndexDeleted, true)
|
||||||
.Set(x => x.UpdatedTime, DateTime.UtcNow)
|
.Set(x => x.UpdatedTime, DateTime.Now)
|
||||||
.Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
.Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||||
|
|
||||||
public Task<List<long>> GetBatchAsync(long afterId, int size, bool pendingOnly) => Select
|
public Task<List<long>> GetBatchAsync(long afterId, int size, bool pendingOnly) => Select
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
var pending = await _repository.Select.As("g")
|
var pending = await _repository.Select.As("g")
|
||||||
.Where(x => x.IsSegmented == 0 && x.Status == 0)
|
.Where(x => x.IsSegmented == 0 && x.Status == 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)")
|
||||||
.OrderBy(x => x.GoodsId)
|
//.OrderBy(x => x.GoodsId)
|
||||||
.Limit(batchSize)
|
.Limit(batchSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Store.Modules.CloudProduct.Services;
|
namespace Store.Modules.CloudProduct.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 违规商品搜索索引清理任务,由 CloudProductPlugin 注册为每分钟执行一次。
|
||||||
|
/// 每批处理最多 1000 个尚未完成索引删除的违规商品,循环处理至无待处理记录。
|
||||||
|
/// </summary>
|
||||||
public class GoodsViolationIndexJob(IServiceScopeFactory scopeFactory)
|
public class GoodsViolationIndexJob(IServiceScopeFactory scopeFactory)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 删除待处理商品的 OpenSearch 索引,成功后将违规记录的 IsIndexDeleted 标记为 true。
|
||||||
|
/// 失败时异常交由 Hangfire 处理,尚未标记完成的记录留待后续执行继续处理。
|
||||||
|
/// </summary>
|
||||||
public async Task ProcessPendingAsync()
|
public async Task ProcessPendingAsync()
|
||||||
{
|
{
|
||||||
|
// 为本次任务创建独立作用域,执行结束后释放业务服务及其作用域依赖。
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
await scope.ServiceProvider.GetRequiredService<GoodsViolationService>()
|
await scope.ServiceProvider.GetRequiredService<GoodsViolationService>()
|
||||||
.DeleteViolationIndexesAsync();
|
.DeleteViolationIndexesAsync();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using MAX.Core.DependencyInjection;
|
|||||||
using MAX.Framework.Commons;
|
using MAX.Framework.Commons;
|
||||||
using MAX.Framework.Dtos;
|
using MAX.Framework.Dtos;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Store.Modules.CloudProduct.Entities;
|
||||||
using Store.Modules.CloudProduct.Dtos;
|
using Store.Modules.CloudProduct.Dtos;
|
||||||
using Store.Modules.CloudProduct.Repositorys;
|
using Store.Modules.CloudProduct.Repositorys;
|
||||||
|
|
||||||
@@ -56,13 +57,53 @@ public class GoodsViolationService(
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>整批校验后统一写入日志,每个事件保留一条记录。</summary>
|
||||||
|
public async Task RecordLogsAsync(RecordViolationLogRequest[]? items)
|
||||||
|
{
|
||||||
|
if (items == null || items.Length is < 1 or > 1000)
|
||||||
|
throw new AppException("每次请提供1-1000条违规日志", ResultCode.ValidationError);
|
||||||
|
|
||||||
|
var logs = new GoodsViolationLogEntity[items.Length];
|
||||||
|
var now = DateTime.Now;
|
||||||
|
for (var i = 0; i < items.Length; i++)
|
||||||
|
{
|
||||||
|
var item = items[i];
|
||||||
|
if (item == null || string.IsNullOrWhiteSpace(item.ShopId) || item.ShopId.Trim().Length > 64
|
||||||
|
|| item.GoodsId <= 0 || item.ReasonId <= 0
|
||||||
|
|| item.Action is not (ViolationLogAction.Report or ViolationLogAction.AutoListingExcluded or ViolationLogAction.AutoOffShelf))
|
||||||
|
throw new AppException($"第{i + 1}条日志须提供有效店铺编号、商品编号、违规类型和动作(1、2或3)", ResultCode.ValidationError);
|
||||||
|
logs[i] = new GoodsViolationLogEntity
|
||||||
|
{
|
||||||
|
ShopId = item.ShopId.Trim(), GoodsId = item.GoodsId, ReasonId = item.ReasonId,
|
||||||
|
Action = item.Action, CreatedTime = now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var reasonIds = logs.Select(x => (uint)x.ReasonId).Distinct().ToArray();
|
||||||
|
if (await repository.CountReasonsAsync(reasonIds) != reasonIds.Length)
|
||||||
|
throw new AppException("违规类型不存在,请从违规字典中选择", ResultCode.ValidationError);
|
||||||
|
await repository.InsertLogsAsync(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>分页读取违规事件,beforeId 为上一页最后一条日志编号。</summary>
|
||||||
|
public Task<List<GoodsViolationLogEntity>> GetLogsAsync(string[]? shopIds, long? goodsId, long beforeId, int size)
|
||||||
|
{
|
||||||
|
if (shopIds == null || shopIds.Length is < 1 or > 1000
|
||||||
|
|| shopIds.Any(id => string.IsNullOrWhiteSpace(id) || id.Trim().Length > 64)
|
||||||
|
|| goodsId <= 0 || beforeId < 0 || size is < 1 or > 200)
|
||||||
|
throw new AppException("请提供1-1000个有效商铺编号、有效的商品及分页参数,每页允许1-200条", ResultCode.ValidationError);
|
||||||
|
return repository.GetLogsAsync(shopIds.Select(id => id.Trim()).Distinct().ToArray(), goodsId, beforeId, size);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<GoodsViolationStatus>> GetStatusAsync(long[]? ids)
|
public async Task<List<GoodsViolationStatus>> GetStatusAsync(long[]? ids)
|
||||||
{
|
{
|
||||||
if (ids == null || ids.Length is < 1 or > 1000 || ids.Any(x => x <= 0))
|
if (ids == null || ids.Length is < 1 or > 1000 || ids.Any(x => x <= 0))
|
||||||
throw new AppException("请提供1-1000个正数商品编号", ResultCode.ValidationError);
|
throw new AppException("请提供1-1000个正数商品编号", ResultCode.ValidationError);
|
||||||
var distinct = ids.Distinct().ToArray();
|
var distinct = ids.Distinct().ToArray();
|
||||||
var violations = (await repository.GetViolationIdsAsync(distinct)).ToHashSet();
|
var reasons = await repository.GetReasonIdsByGoodsAsync(distinct);
|
||||||
return distinct.Select(x => new GoodsViolationStatus { GoodsId = x, IsViolation = violations.Contains(x) }).ToList();
|
return distinct.Select(id => new GoodsViolationStatus
|
||||||
|
{
|
||||||
|
GoodsId = id, IsViolation = reasons.Contains(id), ReasonIds = reasons[id].ToArray()
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RemoveGoodsViolationsResult> RemoveAsync(long[]? ids)
|
public async Task<RemoveGoodsViolationsResult> RemoveAsync(long[]? ids)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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;
|
||||||
|
private readonly string ApiKey;
|
||||||
|
/// <summary>从站点根地址创建客户端,定期更新连接以跟随 DNS 变化。</summary>
|
||||||
|
public ImageAnalysisQuotaService(PluginContext<CloudProductPlugin> context)
|
||||||
|
{
|
||||||
|
this.context = context;
|
||||||
|
var url = context.RequireConfiguration("ImageAnalysisQuota:Url");
|
||||||
|
ApiKey = context.RequireConfiguration("ImageAnalysisQuota:APIKey");
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var endpoint) ||
|
||||||
|
(endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
|
||||||
|
throw new InvalidOperationException("ImageAnalysisQuota:Url 必须是 HTTP 或 HTTPS 站点地址。");
|
||||||
|
if (string.IsNullOrEmpty(ApiKey))
|
||||||
|
throw new InvalidOperationException("ApiKey 不能为空");
|
||||||
|
|
||||||
|
client = new HttpClient(new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
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"] = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用指定用户的识别额度,返回外部接口原始响应;HTTP 失败直接抛出异常。
|
||||||
|
/// 额度扣减不自动重试,避免请求已执行但响应丢失时重复扣减。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> GetUserImageAnalysisQuota(UseImageAnalysisQuotaRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
RequestValidator.Validate(request);
|
||||||
|
// 外部协议要求 token 和 username 放在查询字符串中,统一编码特殊字符。
|
||||||
|
var requestUrl = QueryHelpers.AddQueryString("/wxxd_user/GetUserImageAnalysisQuota", new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["token"] = ApiKey,
|
||||||
|
["username"] = request.UserName
|
||||||
|
});
|
||||||
|
using var response = await client.PostAsync(requestUrl, null, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// shortcut: 尚未提供响应协议,明确业务成功字段后再添加响应 DTO 和业务失败判断。
|
||||||
|
string html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
var result = JsonSerializer.Deserialize<ApiResult<int>>(html, options);
|
||||||
|
if (result != null && result.Success)
|
||||||
|
{
|
||||||
|
return result.Data;
|
||||||
|
}
|
||||||
|
throw new AppException(result.Msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using MAX.Core.DependencyInjection;
|
||||||
|
using MAX.Framework.Commons;
|
||||||
|
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();
|
||||||
|
var result = cached?.ReadFreshResult(DateTime.UtcNow);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var useImageQuota = await imageAnalysisQuotaService.GetUserImageAnalysisQuota(new UseImageAnalysisQuotaRequest
|
||||||
|
{
|
||||||
|
UserName = request.UserName
|
||||||
|
});
|
||||||
|
if (useImageQuota <= 0)
|
||||||
|
{
|
||||||
|
throw new AppException("AI检测可用数量为0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "调用图片识别扣费程序失败");
|
||||||
|
throw new AppException("AI检测可用数量为0");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(image, writable: false);
|
||||||
|
var recognized = await integration.AnalyzeAsync(stream, mediaType, cancellationToken);
|
||||||
|
result = recognized.Distinct(StringComparer.Ordinal).ToArray();
|
||||||
|
var entry = cached ?? new ImageAnalysisCache { CacheKey = md5 };
|
||||||
|
var previousUpdate = entry.UpdatedAt;
|
||||||
|
entry.RecognitionResult = JsonSerializer.Serialize(result, CacheJsonOptions);
|
||||||
|
entry.RecordExpiration(result.Length == 0, DateTime.UtcNow);
|
||||||
|
// 同一缓存失效后可能同时触发多次识别,只有首个写入者推进级别。
|
||||||
|
if (cached is null)
|
||||||
|
await database.InsertOrUpdate<ImageAnalysisCache>().SetSource(entry).IfExistsDoNothing().ExecuteAffrowsAsync();
|
||||||
|
else
|
||||||
|
await database.Update<ImageAnalysisCache>().SetSource(entry)
|
||||||
|
.Where(x => x.UpdatedAt == previousUpdate).ExecuteAffrowsAsync();
|
||||||
|
}
|
||||||
|
finally { gate.Release(); }
|
||||||
|
}
|
||||||
|
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,11 +20,14 @@ 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);
|
||||||
|
|
||||||
/// <summary>创建收藏分组:创建成功返回 true,同名分组已存在返回 false</summary>
|
/// <summary>创建收藏分组:创建成功返回 true,同名分组已存在返回 false</summary>
|
||||||
Task<bool> CreateGroupAsync(CreateUserFavoriteGroupRequest request);
|
Task<bool> CreateGroupAsync(UserFavoriteGroupRequest request);
|
||||||
|
|
||||||
/// <summary>查询用户的收藏分组(含默认分组)及分组下收藏数量</summary>
|
/// <summary>查询用户的收藏分组(含默认分组)及分组下收藏数量</summary>
|
||||||
Task<List<UserFavoriteGroupDto>> GetGroupsAsync(string userName);
|
Task<List<UserFavoriteGroupDto>> GetGroupsAsync(string userName);
|
||||||
@@ -34,6 +40,8 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
|
|
||||||
/// <summary>移除所有用户收藏索引并按数据库数据全量重建,返回重建的文档数</summary>
|
/// <summary>移除所有用户收藏索引并按数据库数据全量重建,返回重建的文档数</summary>
|
||||||
Task<long> RebuildSearchIndexAsync();
|
Task<long> RebuildSearchIndexAsync();
|
||||||
|
Task<bool> DelGroupAsync(UserFavoriteGroupRequest request);
|
||||||
|
Task<bool> SetFavoriteGroupAsync(SetUserFavoriteGroupRequest request);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class UserFavoritesService : IUserFavoritesService
|
internal class UserFavoritesService : IUserFavoritesService
|
||||||
@@ -47,6 +55,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 +64,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 +94,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 +108,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 +222,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 +267,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();
|
||||||
@@ -261,7 +300,7 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建收藏分组:创建成功返回 true,同一用户同名分组已存在返回 false。
|
/// 创建收藏分组:创建成功返回 true,同一用户同名分组已存在返回 false。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<bool> CreateGroupAsync(CreateUserFavoriteGroupRequest request)
|
public async Task<bool> CreateGroupAsync(UserFavoriteGroupRequest request)
|
||||||
{
|
{
|
||||||
if (request == null)
|
if (request == null)
|
||||||
{
|
{
|
||||||
@@ -319,36 +358,160 @@ namespace Store.Modules.CloudProduct.Services
|
|||||||
.OrderBy(x => x.Id)
|
.OrderBy(x => x.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var countList = await _favoriteRepository.Select
|
//var countList = await _favoriteRepository.Select
|
||||||
.Where(x => x.UserName == userName)
|
// .Where(x => x.UserName == userName)
|
||||||
.GroupBy(x => x.GroupId)
|
// .GroupBy(x => x.GroupId)
|
||||||
.ToListAsync(x => new { GroupId = x.Key, Count = x.Count() });
|
// .ToListAsync(x => new { GroupId = x.Key, Count = x.Count() });
|
||||||
|
|
||||||
var countMap = countList.ToDictionary(x => x.GroupId, x => (long)x.Count);
|
//var countMap = countList.ToDictionary(x => x.GroupId, x => (long)x.Count);
|
||||||
|
|
||||||
var result = new List<UserFavoriteGroupDto>();
|
var result = new List<UserFavoriteGroupDto>();
|
||||||
|
|
||||||
if (countMap.TryGetValue(0, out var defaultCount) && defaultCount > 0)
|
//if (countMap.TryGetValue(0, out var defaultCount) && defaultCount > 0)
|
||||||
{
|
//{
|
||||||
result.Add(new UserFavoriteGroupDto
|
// result.Add(new UserFavoriteGroupDto
|
||||||
{
|
// {
|
||||||
Id = 0,
|
// Id = 0,
|
||||||
UserName = userName,
|
// UserName = userName,
|
||||||
GroupName = DefaultGroupName,
|
// GroupName = DefaultGroupName,
|
||||||
FavoriteCount = defaultCount,
|
// FavoriteCount = defaultCount,
|
||||||
});
|
// });
|
||||||
}
|
//}
|
||||||
|
|
||||||
result.AddRange(groups.Select(x => new UserFavoriteGroupDto
|
result.AddRange(groups.Select(x => new UserFavoriteGroupDto
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
UserName = x.UserName,
|
UserName = x.UserName,
|
||||||
GroupName = x.GroupName,
|
GroupName = x.GroupName,
|
||||||
FavoriteCount = countMap.TryGetValue((int)x.Id, out var count) ? count : 0,
|
//FavoriteCount = countMap.TryGetValue((int)x.Id, out var count) ? count : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// 删除用户分组
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="AppException"></exception>
|
||||||
|
public async Task<bool> DelGroupAsync(UserFavoriteGroupRequest request)
|
||||||
|
{
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(userName))
|
||||||
|
{
|
||||||
|
throw new AppException("用户名不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupName = request.GroupName?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(groupName))
|
||||||
|
{
|
||||||
|
throw new AppException("分组名不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupName.Length > 255)
|
||||||
|
{
|
||||||
|
throw new AppException("分组名长度不能超过255个字符", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
//_favoriteRepository.Select.Where(a=>a.Id)
|
||||||
|
|
||||||
|
var existing = await _groupRepository.Select
|
||||||
|
.Where(x => x.UserName == userName && x.GroupName == groupName)
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
var favorites = await _favoriteRepository.Select
|
||||||
|
.Where(x => x.UserName == userName && x.GroupId == existing.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
var goodsIds = favorites.Select(x => x.GoodsId).ToList();
|
||||||
|
var goodsMap = goodsIds.Count == 0
|
||||||
|
? new Dictionary<long, GoodsEntity>()
|
||||||
|
: (await _goodsRepository.Select.Where(x => goodsIds.Contains(x.GoodsId)).ToListAsync())
|
||||||
|
.ToDictionary(x => x.GoodsId);
|
||||||
|
|
||||||
|
await _favoriteRepository.Orm.Update<UserGoodsFavoriteEntity>()
|
||||||
|
.Set(a => a.GroupId, 0)
|
||||||
|
.Where(a => a.UserName == userName && a.GroupId == existing.Id)
|
||||||
|
.ExecuteAffrowsAsync();
|
||||||
|
|
||||||
|
foreach (var favorite in favorites)
|
||||||
|
{
|
||||||
|
favorite.GroupId = 0;
|
||||||
|
}
|
||||||
|
await _indexService.IndexDocumentsAsync(favorites
|
||||||
|
.Where(x => goodsMap.ContainsKey(x.GoodsId))
|
||||||
|
.Select(x => BuildDocument(x, goodsMap[x.GoodsId], DefaultGroupName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await _groupRepository.DeleteAsync(a => a.UserName == userName && a.GroupName == groupName);
|
||||||
|
if (rows != 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>将用户已收藏的商品移动到其指定分组,并同步收藏索引。</summary>
|
||||||
|
public async Task<bool> SetFavoriteGroupAsync(SetUserFavoriteGroupRequest request)
|
||||||
|
{
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
throw new AppException("请求参数不能为空", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var userName = request.UserName?.Trim() ?? string.Empty;
|
||||||
|
var goodsIds = request.GoodsIds?.Where(x => x > 0).Distinct().ToList() ?? new List<long>();
|
||||||
|
if (string.IsNullOrWhiteSpace(userName) || goodsIds.Count == 0 || request.GroupId < 0)
|
||||||
|
{
|
||||||
|
throw new AppException("用户、商品或分组参数无效", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainUser = await _queryService.GetMainUser(userName);
|
||||||
|
userName = mainUser.UserName;
|
||||||
|
await using var lease = await _indexLock.AcquireFavoritesAsync();
|
||||||
|
|
||||||
|
var favorites = await _favoriteRepository.Select
|
||||||
|
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
|
||||||
|
if (request.GroupId > 0 && await _groupRepository.Select
|
||||||
|
.Where(x => x.Id == request.GroupId && x.UserName == userName)
|
||||||
|
.FirstAsync() == null)
|
||||||
|
{
|
||||||
|
throw new AppException("收藏分组不存在或不属于该用户", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var goodsList = await _goodsRepository.Select.Where(x => goodsIds.Contains(x.GoodsId)).ToListAsync();
|
||||||
|
if (goodsList.Count != goodsIds.Count)
|
||||||
|
{
|
||||||
|
throw new AppException("部分商品不存在", ResultCode.ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedFavorites = favorites.Where(x => x.GroupId != request.GroupId).ToList();
|
||||||
|
if (changedFavorites.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _favoriteRepository.Orm.Update<UserGoodsFavoriteEntity>()
|
||||||
|
.Set(x => x.GroupId, request.GroupId)
|
||||||
|
.Where(x => x.UserName == userName && goodsIds.Contains(x.GoodsId))
|
||||||
|
.ExecuteAffrowsAsync();
|
||||||
|
foreach (var favorite in changedFavorites)
|
||||||
|
{
|
||||||
|
favorite.GroupId = request.GroupId;
|
||||||
|
}
|
||||||
|
var goodsMap = goodsList.ToDictionary(x => x.GoodsId);
|
||||||
|
var groupName = await ResolveGroupNameAsync(userName, request.GroupId);
|
||||||
|
await _indexService.IndexDocumentsAsync(changedFavorites.Select(x => BuildDocument(x, goodsMap[x.GoodsId], groupName)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按 GoodsIds 顺序返回每个商品是否已被该用户收藏(商品编号无效的返回 false)。
|
/// 按 GoodsIds 顺序返回每个商品是否已被该用户收藏(商品编号无效的返回 false)。
|
||||||
@@ -404,8 +567,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 +580,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 +710,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 +733,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>
|
||||||
|
|
||||||
|
|
||||||
@@ -23,12 +23,7 @@
|
|||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
|
||||||
<PackageReference Include="Hangfire.Dashboard.Basic.Authentication" Version="8.0.0" />
|
|
||||||
<PackageReference Include="Hangfire.MySql" Version="1.0.0" />
|
|
||||||
<PackageReference Include="OpenSearch.Client" Version="2.1.0" />
|
<PackageReference Include="OpenSearch.Client" Version="2.1.0" />
|
||||||
<!-- 覆盖 Hangfire 的旧传递依赖,修复 CVE-2024-21907。 -->
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"CloudProductConnection": "Server=192.168.0.200;Port=3306;Database=goods_center;Uid=root;Pwd=123456;Allow User Variables=true",
|
"CloudProductConnection": "Server=192.168.0.200;Port=3306;Database=goods_center;Uid=root;Pwd=123456;Allow User Variables=true",
|
||||||
|
|
||||||
|
//"CloudProductConnection": "Server=103.90.155.78;Port=1567;Database=goods_center;Uid=goods_center;Pwd=GnSafXLMcaCTG6N7;Allow User Variables=true",
|
||||||
"Search": {
|
"Search": {
|
||||||
"Url": "http://192.168.0.200:9200",
|
"Url": "http://103.90.155.78:9200",
|
||||||
"IndexName": "goods_search",
|
"IndexName": "goods_search",
|
||||||
"FavoritesIndexName": "user_goods_favorites_search"
|
"FavoritesIndexName": "user_goods_favorites_search"
|
||||||
},
|
},
|
||||||
"RawData": {
|
"RawData": {
|
||||||
"Path": "E:\\GoodsRawData"
|
"Path": "Z:\\GoodsRawData"
|
||||||
},
|
},
|
||||||
"Indexing": {
|
"Indexing": {
|
||||||
"Mode": "Background",
|
"Mode": "Background",
|
||||||
@@ -16,10 +18,14 @@
|
|||||||
"Delete": {
|
"Delete": {
|
||||||
"SoftDelete": true
|
"SoftDelete": true
|
||||||
},
|
},
|
||||||
"Hangfire": {
|
"ImageAnalysisQuota": {
|
||||||
"Dashboard" :{
|
"Url": "http://localhost:9000", //http://119.45.29.75:9000
|
||||||
"User": "admin",
|
"APIKey": "e3b0c44298fc1c149afbf4c8996fb924"
|
||||||
"Pass": "1234560"
|
},
|
||||||
}
|
"ProductInspection": {
|
||||||
|
"Endpoint": "https://llm-lq2jys2ky6yxo7rh.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||||
|
"Model": "qwen3.7-flash",
|
||||||
|
"ApiKey": "sk-fa7fbaa3bb4047928f30bf05ff52c03a",
|
||||||
|
"MaxConcurrentAnalysis": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
<_ObsoletePluginEntry Include="@(_ObsoletePluginDeps->'%(RootDir)%(Directory)%(EntryName).runtimeconfig.json')" />
|
<_ObsoletePluginEntry Include="@(_ObsoletePluginDeps->'%(RootDir)%(Directory)%(EntryName).runtimeconfig.json')" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Delete Files="@(_ObsoletePluginEntry)" />
|
<Delete Files="@(_ObsoletePluginEntry)" />
|
||||||
|
<!-- MAX.Framework 的 Serilog 配置仅由宿主读取,同时清理旧插件部署目录中的副本。 -->
|
||||||
|
<Delete Files="$(PluginDeploymentDirectory)appsettings.Serilog.json" />
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 保留 runtimes 等子目录,包含入口、deps.json、配置及传递依赖。 -->
|
<!-- 保留 runtimes 等子目录,包含入口、deps.json、配置及传递依赖。 -->
|
||||||
<_PluginDeploymentFile Include="$(TargetDir)**\*" Exclude="$(TargetDir)publish\**\*" />
|
<_PluginDeploymentFile Include="$(TargetDir)**\*" Exclude="$(TargetDir)publish\**\*;$(TargetDir)appsettings.Serilog.json" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Copy SourceFiles="@(_PluginDeploymentFile)"
|
<Copy SourceFiles="@(_PluginDeploymentFile)"
|
||||||
DestinationFiles="@(_PluginDeploymentFile->'$(PluginDeploymentDirectory)%(RecursiveDir)%(Filename)%(Extension)')"
|
DestinationFiles="@(_PluginDeploymentFile->'$(PluginDeploymentDirectory)%(RecursiveDir)%(Filename)%(Extension)')"
|
||||||
|
|||||||
+11
-2
@@ -66,8 +66,16 @@ public ExampleService([FromKeyedServices("CloudProduct")] PluginContext context)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
数据库、Hangfire、OpenSearch 保留在各自插件内;认证、日志等仍由宿主管理。
|
业务数据库和 OpenSearch 保留在各自插件内;Hangfire、认证、日志由宿主管理。
|
||||||
CloudProduct 的搜索启动检查使用 BackgroundService,网络错误记录日志,不阻断 HTTP 启动;Hangfire 仍按既有配置运行。
|
CloudProduct 的搜索启动检查使用 BackgroundService,网络错误记录日志,不阻断 HTTP 启动。
|
||||||
|
|
||||||
|
### 宿主 Hangfire 配置
|
||||||
|
|
||||||
|
宿主通过 MAX.Framework 的 AddStoreHangfire 注册共享存储和 Worker,在认证、授权中间件之后调用 UseStoreHangfire 挂载 `/hangfire`。插件只注册自己的 Job 和定时任务。
|
||||||
|
|
||||||
|
宿主 appsettings.json 必须提供 `Hangfire:ConnectionString`、`Hangfire:Dashboard:User` 和 `Hangfire:Dashboard:Pass`。可用环境变量 `Hangfire__ConnectionString`、`Hangfire__Dashboard__User`、`Hangfire__Dashboard__Pass` 覆盖。原 `Plugins__CloudProduct__Hangfire__Dashboard__*` 配置需迁移至宿主。
|
||||||
|
|
||||||
|
初始连接指向原 CloudProduct 任务库,继续使用 `Hangfire_` 表和已有任务。部署时将实际 CloudProductConnection 对应的任务库连接填入宿主配置,保留原 Job 程序集、类型、方法和定时任务 ID。Hangfire 使用独立 FreeSql 单例,云商品业务仍使用插件的 CloudProductConnection。数据库恢复后,定时任务注册失败的实例需重启以重新注册。
|
||||||
|
|
||||||
## 新增插件
|
## 新增插件
|
||||||
|
|
||||||
@@ -81,6 +89,7 @@ CloudProduct 的搜索启动检查使用 BackgroundService,网络错误记录
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project Tests/PluginLoadingSmoke -- configuration
|
dotnet run --project Tests/PluginLoadingSmoke -- configuration
|
||||||
|
dotnet run --project Tests/PluginLoadingSmoke -- hangfire
|
||||||
dotnet run --project Tests/PluginLoadingSmoke -- "$PWD/Bin/Plugins"
|
dotnet run --project Tests/PluginLoadingSmoke -- "$PWD/Bin/Plugins"
|
||||||
dotnet run --project Tests/PluginLoadingSmoke -- "$PWD/Bin/Plugins/Store.ImageCache" image
|
dotnet run --project Tests/PluginLoadingSmoke -- "$PWD/Bin/Plugins/Store.ImageCache" image
|
||||||
dotnet run --project Tests/PluginLoadingSmoke -- <空目录或不存在目录> empty
|
dotnet run --project Tests/PluginLoadingSmoke -- <空目录或不存在目录> empty
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +16,7 @@ public partial class Program
|
|||||||
// 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块)
|
// 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块)
|
||||||
builder.AddStore();
|
builder.AddStore();
|
||||||
builder.Services.AddFreeSqlDB();
|
builder.Services.AddFreeSqlDB();
|
||||||
|
builder.Services.AddStoreHangfire(builder.Configuration);
|
||||||
// 数据库 Token 认证,配置节 TokenAuth
|
// 数据库 Token 认证,配置节 TokenAuth
|
||||||
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
|
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
@@ -78,6 +81,11 @@ public partial class Program
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseStoreHangfire();
|
||||||
|
// UTC 19:00 即北京时间次日 03:00;固定任务 ID 避免重启后重复注册。
|
||||||
|
app.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<TokenService>(
|
||||||
|
"token-cleanup", service => service.CleanupExpiredTokens(), Cron.Daily(19),
|
||||||
|
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,19 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Modules\Store.Users\Store.Users.csproj" />
|
<ProjectReference Include="..\Modules\Store.Users\Store.Users.csproj" />
|
||||||
<ProjectReference Include="..\Store.Framework\Store.Framework.csproj" />
|
<ProjectReference Include="..\Store.Framework\Store.Framework.csproj" />
|
||||||
|
<!-- 生成插件供宿主动态加载,插件程序集和配置不合并到宿主根目录。 -->
|
||||||
|
<ProjectReference Include="..\Plugins\*\*.csproj" ReferenceOutputAssembly="false" Private="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="IncludePluginsInPublish" AfterTargets="ComputeFilesToPublish">
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 插件生成后才枚举部署文件,保留各插件的配置和运行时依赖目录。 -->
|
||||||
|
<_PluginPublishFile Include="$(StoreHostOutputPath)Plugins\**\*" />
|
||||||
|
<ResolvedFileToPublish Include="@(_PluginPublishFile)">
|
||||||
|
<RelativePath>Plugins\%(_PluginPublishFile.RecursiveDir)%(_PluginPublishFile.Filename)%(_PluginPublishFile.Extension)</RelativePath>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</ResolvedFileToPublish>
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -6,20 +6,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
|
"DefaultConnection": "Max Pool Size=512;Database='kjd';Data Source='nj-cynosdbmysql-grp-hl51pkfr.sql.tencentcdb.com';Port=27660;User Id='kjd';Password='Temukjd0625';Charset=utf8mb4;"
|
||||||
},
|
},
|
||||||
|
// 登录认证:请求头使用 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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Hangfire": {
|
||||||
|
"Dashboard": {
|
||||||
|
"User": "admin",
|
||||||
|
"Pass": "1234560"
|
||||||
|
},
|
||||||
|
"ConnectionString": "Server=192.168.0.200;Port=3306;Database=goods_center;Uid=root;Pwd=123456;Allow User Variables=true"
|
||||||
|
},
|
||||||
"Plugins": {
|
"Plugins": {
|
||||||
"Path": "Plugins"
|
"Path": "Plugins"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Store.Modules.CloudProduct.Enums
|
namespace Store.Framework.Models
|
||||||
{
|
{
|
||||||
public enum PlatformType
|
public enum PlatformType
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Store.Framework.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通用商品Model
|
||||||
|
/// </summary>
|
||||||
|
public class Product
|
||||||
|
{
|
||||||
|
public Product()
|
||||||
|
{
|
||||||
|
CategoryIds = new List<int>();
|
||||||
|
Attributes = new List<ProductAttribute>();
|
||||||
|
MainImages = new List<string>();
|
||||||
|
DetailImages = new List<string>();
|
||||||
|
Specs = new List<ProductSpec>();
|
||||||
|
Skus = new List<ProductSku>();
|
||||||
|
Extensions = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 来源
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源平台
|
||||||
|
/// </summary>
|
||||||
|
public PlatformType Platform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源商品链接
|
||||||
|
/// </summary>
|
||||||
|
public string SourceUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源店铺编号
|
||||||
|
/// </summary>
|
||||||
|
public string ShopId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源店铺名称
|
||||||
|
/// </summary>
|
||||||
|
public string ShopName { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 基本信息
|
||||||
|
/// <summary>
|
||||||
|
/// 商家编号
|
||||||
|
/// </summary>
|
||||||
|
public string ProductId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 标题
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 品牌
|
||||||
|
/// </summary>
|
||||||
|
public string Brand { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 类目
|
||||||
|
/// </summary>
|
||||||
|
public int CategoryId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 类目编号路径,从一级类目到叶子类目,叶子类目为最后一项
|
||||||
|
/// </summary>
|
||||||
|
public List<int> CategoryIds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品属性(非销售维度),如材质、产地
|
||||||
|
/// </summary>
|
||||||
|
public List<ProductAttribute> Attributes { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 媒体
|
||||||
|
/// <summary>
|
||||||
|
/// 缩略图
|
||||||
|
/// </summary>
|
||||||
|
public string ThumbUrl { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 主图
|
||||||
|
/// </summary>
|
||||||
|
public List<string> MainImages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 详情图
|
||||||
|
/// </summary>
|
||||||
|
public List<string> DetailImages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 视频地址,可为空
|
||||||
|
/// </summary>
|
||||||
|
public string Video { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 销售
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格
|
||||||
|
/// </summary>
|
||||||
|
public List<ProductSpec> Specs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU
|
||||||
|
/// </summary>
|
||||||
|
public List<ProductSku> Skus { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 扩展数据
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> Extensions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 划线价/市场价(商品级)
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MarketPrice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 销量
|
||||||
|
/// </summary>
|
||||||
|
public long? SalesCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源平台展示的销量文案,如“已拼10万+件”
|
||||||
|
/// </summary>
|
||||||
|
public string SalesText { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 来源平台上架时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? ListingTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU最低售价
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public decimal MinPrice
|
||||||
|
{
|
||||||
|
get { return Skus.Any() ? Skus.Min(a => a.Price) : 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU最高售价
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public decimal MaxPrice
|
||||||
|
{
|
||||||
|
get { return Skus.Any() ? Skus.Max(a => a.Price) : 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 所有SKU库存总和
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public int TotalStock
|
||||||
|
{
|
||||||
|
get { return Skus.Sum(a => a.Stock); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验商品数据,返回错误信息,null 表示通过。
|
||||||
|
/// </summary>
|
||||||
|
public string Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ProductId)) return "缺少商品编号";
|
||||||
|
if (MainImages.Count == 0) return "缺少主图";
|
||||||
|
if (Skus.Count == 0) return "缺少SKU";
|
||||||
|
|
||||||
|
if (Specs.Any(s => string.IsNullOrEmpty(s.Name) || s.Values.Count == 0))
|
||||||
|
return "规格名称为空或规格值为空";
|
||||||
|
if (Specs.GroupBy(s => s.Name).Any(g => g.Count() > 1))
|
||||||
|
return "规格名称重复";
|
||||||
|
if (Specs.Any(s => s.Values.Distinct().Count() != s.Values.Count))
|
||||||
|
return "同一规格下规格值重复";
|
||||||
|
|
||||||
|
foreach (var sku in Skus)
|
||||||
|
{
|
||||||
|
// 每个 SKU 在每个规格维度上恰好选一个值
|
||||||
|
if (sku.Specs.Count != Specs.Count)
|
||||||
|
return "SKU规格数量与商品规格不一致:" + sku.SkuId;
|
||||||
|
foreach (var s in sku.Specs)
|
||||||
|
{
|
||||||
|
var spec = Specs.FirstOrDefault(a => a.Name == s.Name);
|
||||||
|
if (spec == null)
|
||||||
|
return "SKU使用了不存在的规格:" + s.Name;
|
||||||
|
if (!spec.Values.Contains(s.Value))
|
||||||
|
return "SKU使用了不存在的规格值:" + s.Name + ":" + s.Value;
|
||||||
|
}
|
||||||
|
if (sku.Specs.GroupBy(a => a.Name).Any(g => g.Count() > 1))
|
||||||
|
return "SKU同一规格重复选择:" + sku.SkuId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品规格定义,如 颜色:红色、蓝色
|
||||||
|
/// </summary>
|
||||||
|
public class ProductSpec
|
||||||
|
{
|
||||||
|
public ProductSpec()
|
||||||
|
{
|
||||||
|
Values = new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格名称,如颜色、尺码,商品内唯一
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格可选值,如红色、XL,规格内唯一
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Values { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品属性(非销售维度)
|
||||||
|
/// </summary>
|
||||||
|
public class ProductAttribute
|
||||||
|
{
|
||||||
|
public ProductAttribute()
|
||||||
|
{
|
||||||
|
Values = new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 属性名称,如材质
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 属性值列表
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Values { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品销售单元
|
||||||
|
/// </summary>
|
||||||
|
public class ProductSku
|
||||||
|
{
|
||||||
|
public ProductSku()
|
||||||
|
{
|
||||||
|
Specs = new List<ProductSkuSpec>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU编号
|
||||||
|
/// </summary>
|
||||||
|
public string SkuId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前售价(元)
|
||||||
|
/// </summary>
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可售库存
|
||||||
|
/// </summary>
|
||||||
|
public int Stock { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU图片
|
||||||
|
/// </summary>
|
||||||
|
public string ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU选中的规格值,Name/Value 必须存在于 Product.Specs
|
||||||
|
/// </summary>
|
||||||
|
public List<ProductSkuSpec> Specs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SKU选中的一个规格值
|
||||||
|
/// </summary>
|
||||||
|
public class ProductSkuSpec
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 规格名称,对应 ProductSpec.Name
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 规格值,对应 ProductSpec.Values 中的一项
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; set; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -120,6 +120,26 @@ internal static class IntegrationChecks
|
|||||||
new() { GoodsId = first, Reasons = ignoredName }
|
new() { GoodsId = first, Reasons = ignoredName }
|
||||||
]);
|
]);
|
||||||
Check(result.Count == 1 && result[0].GoodsId == first && result[0].IsOffShelf, "batch report and deduplication");
|
Check(result.Count == 1 && result[0].GoodsId == first && result[0].IsOffShelf, "batch report and deduplication");
|
||||||
|
var ignoredReasonId = (await db.Select<ViolationReasonEntity>().Where(x => x.Name == ignoredName).FirstAsync()).Id;
|
||||||
|
Check(!await db.Select<GoodsViolationLogEntity>().Where(x => x.GoodsId == first).AnyAsync(), "reporting alone creates no log");
|
||||||
|
await violations.RecordLogsAsync([
|
||||||
|
new() { ShopId = "shop-a", GoodsId = first, ReasonId = (int)existingReasonId, Action = ViolationLogAction.Report },
|
||||||
|
]);
|
||||||
|
await violations.RecordLogsAsync([
|
||||||
|
new() { ShopId = "shop-a", GoodsId = first, ReasonId = (int)existingReasonId, Action = ViolationLogAction.Report },
|
||||||
|
new() { ShopId = "shop-c", GoodsId = first, ReasonId = (int)ignoredReasonId, Action = ViolationLogAction.Report },
|
||||||
|
new() { ShopId = "shop-c", GoodsId = first, ReasonId = (int)ignoredReasonId, Action = ViolationLogAction.Report }
|
||||||
|
]);
|
||||||
|
var reportLogs = await violations.GetLogsAsync(["shop-a", "shop-c"], first, 0, 50);
|
||||||
|
Check(reportLogs.Count == 2 && reportLogs.All(x => x.Action == ViolationLogAction.Report)
|
||||||
|
&& reportLogs.Any(x => x.ShopId == "shop-a" && x.ReasonId == existingReasonId)
|
||||||
|
&& reportLogs.Any(x => x.ShopId == "shop-c" && x.ReasonId == ignoredReasonId), "skip existing and in-batch duplicate events while keeping new events and their sources");
|
||||||
|
foreach (var action in new[] { ViolationLogAction.AutoListingExcluded, ViolationLogAction.AutoOffShelf })
|
||||||
|
await violations.RecordLogsAsync([new() { ShopId = "shop-b", GoodsId = first, ReasonId = (int)existingReasonId, Action = action }]);
|
||||||
|
var latest = (await violations.GetLogsAsync(["shop-b"], first, 0, 1)).Single();
|
||||||
|
var previous = (await violations.GetLogsAsync(["shop-b"], first, latest.Id, 1)).Single();
|
||||||
|
Check(latest.Action == ViolationLogAction.AutoOffShelf && previous.Action == ViolationLogAction.AutoListingExcluded,
|
||||||
|
"client actions are filtered by shop and paginated newest first");
|
||||||
Check(!await Exists(first), "report deletes real OpenSearch document");
|
Check(!await Exists(first), "report deletes real OpenSearch document");
|
||||||
reasons.Add((await db.Select<ViolationReasonEntity>().Where(x => x.Name == names[0]).FirstAsync()).Id);
|
reasons.Add((await db.Select<ViolationReasonEntity>().Where(x => x.Name == names[0]).FirstAsync()).Id);
|
||||||
reasons.Add((await db.Select<ViolationReasonEntity>().Where(x => x.Name == names[1]).FirstAsync()).Id);
|
reasons.Add((await db.Select<ViolationReasonEntity>().Where(x => x.Name == names[1]).FirstAsync()).Id);
|
||||||
@@ -165,6 +185,8 @@ internal static class IntegrationChecks
|
|||||||
|
|
||||||
var status = await violations.GetStatusAsync([first, first + 1, first, first + 100]);
|
var status = await violations.GetStatusAsync([first, first + 1, first, first + 100]);
|
||||||
Check(status.Count == 3 && status[0].IsViolation && !status[1].IsViolation && !status[2].IsViolation, "batch status including missing goods");
|
Check(status.Count == 3 && status[0].IsViolation && !status[1].IsViolation && !status[2].IsViolation, "batch status including missing goods");
|
||||||
|
Check(status[0].ReasonIds.ToHashSet().SetEquals(before.Select(id => (int)id))
|
||||||
|
&& status[1].ReasonIds.Length == 0 && status[2].ReasonIds.Length == 0, "status returns only current reasons for each product");
|
||||||
Check((await Reasons(first)).Length == 3, "scalar reasons stored in separate rows");
|
Check((await Reasons(first)).Length == 3, "scalar reasons stored in separate rows");
|
||||||
Check((await violations.GetGoodsIdsByReasonAsync(reasons[2], first - 1)).Contains(first), "scalar reason query");
|
Check((await violations.GetGoodsIdsByReasonAsync(reasons[2], first - 1)).Contains(first), "scalar reason query");
|
||||||
try
|
try
|
||||||
@@ -310,6 +332,7 @@ internal static class IntegrationChecks
|
|||||||
if (ownsIds)
|
if (ownsIds)
|
||||||
{
|
{
|
||||||
await db.Delete<GoodsEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
await db.Delete<GoodsEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||||
|
await db.Delete<GoodsViolationLogEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||||
await db.Delete<GoodsViolationEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
await db.Delete<GoodsViolationEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||||
}
|
}
|
||||||
await db.Delete<ViolationReasonEntity>().Where(x => x.Name == ignoredName).ExecuteAffrowsAsync();
|
await db.Delete<ViolationReasonEntity>().Where(x => x.Name == ignoredName).ExecuteAffrowsAsync();
|
||||||
|
|||||||
@@ -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)")
|
||||||
@@ -81,6 +152,18 @@ try
|
|||||||
catch (JsonException) { checks++; }
|
catch (JsonException) { checks++; }
|
||||||
Check(!typeof(GoodsController).GetMethods().Any(x => x.Name.Contains("ByReason")), "Reason query must not be exposed");
|
Check(!typeof(GoodsController).GetMethods().Any(x => x.Name.Contains("ByReason")), "Reason query must not be exposed");
|
||||||
var removalService = new GoodsViolationService(null!, null!, null!, NullLogger<GoodsViolationService>.Instance);
|
var removalService = new GoodsViolationService(null!, null!, null!, NullLogger<GoodsViolationService>.Instance);
|
||||||
|
Check(JsonSerializer.Serialize(request, new JsonSerializerOptions(JsonSerializerDefaults.Web)).Contains("shopId") == false,
|
||||||
|
"report payload excludes log metadata");
|
||||||
|
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 1, Action = (ViolationLogAction)4 }]).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.RecordLogsAsync([new() { GoodsId = 1, ReasonId = 1, Action = ViolationLogAction.AutoOffShelf }]).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.RecordLogsAsync([]).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.RecordLogsAsync(new RecordViolationLogRequest[1001]).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 1, Action = ViolationLogAction.Report }, null!]).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.GetLogsAsync(["a"], 1, -1, 50).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.GetLogsAsync(["a"], null, 0, 201).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.GetLogsAsync([], null, 0, 50).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.GetLogsAsync(["a", " "], null, 0, 50).GetAwaiter().GetResult());
|
||||||
|
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 0, Action = ViolationLogAction.AutoOffShelf }]).GetAwaiter().GetResult());
|
||||||
Reject(() => removalService.RemoveAsync(null).GetAwaiter().GetResult());
|
Reject(() => removalService.RemoveAsync(null).GetAwaiter().GetResult());
|
||||||
Reject(() => removalService.RemoveAsync([]).GetAwaiter().GetResult());
|
Reject(() => removalService.RemoveAsync([]).GetAwaiter().GetResult());
|
||||||
Reject(() => removalService.RemoveAsync([0]).GetAwaiter().GetResult());
|
Reject(() => removalService.RemoveAsync([0]).GetAwaiter().GetResult());
|
||||||
@@ -117,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}。");
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using MAX.Framework.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
/// <summary>不连接数据库,检查宿主配置边界及共享服务注册。</summary>
|
||||||
|
internal static class HangfireChecks
|
||||||
|
{
|
||||||
|
public static void Run()
|
||||||
|
{
|
||||||
|
var settings = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Hangfire:ConnectionString"] = "Server=localhost;Database=hangfire;Uid=test;Pwd=test",
|
||||||
|
["Hangfire:Dashboard:User"] = "test",
|
||||||
|
["Hangfire:Dashboard:Pass"] = "test"
|
||||||
|
};
|
||||||
|
foreach (var key in settings.Keys)
|
||||||
|
{
|
||||||
|
foreach (var missing in new string?[] { null, " " })
|
||||||
|
{
|
||||||
|
var invalid = new Dictionary<string, string?>(settings) { [key] = missing };
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
services.AddStoreHangfire(new ConfigurationBuilder().AddInMemoryCollection(invalid).Build());
|
||||||
|
throw new Exception("Expected missing configuration failure: " + key);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) when (ex.Message.Contains(key)) { }
|
||||||
|
if (services.Count != 0) throw new Exception("Invalid configuration partially registered services");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid = new ServiceCollection();
|
||||||
|
valid.AddStoreHangfire(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
|
||||||
|
if (valid.Count(d => d.ServiceType == typeof(IFreeSql) && Equals(d.ServiceKey, "HangfireConnection")) != 1
|
||||||
|
|| valid.Count(d => d.ServiceType == typeof(JobStorage)) != 1
|
||||||
|
|| valid.Count(d => d.ServiceType == typeof(IHostedService)) != 1
|
||||||
|
|| valid.Count(d => d.ServiceType == typeof(DashboardOptions)) != 1)
|
||||||
|
throw new Exception("Expected one shared storage, worker and dashboard registration");
|
||||||
|
using var provider = valid.BuildServiceProvider();
|
||||||
|
var database = provider.GetRequiredKeyedService<IFreeSql>("HangfireConnection");
|
||||||
|
if (!ReferenceEquals(database, provider.GetRequiredKeyedService<IFreeSql>("HangfireConnection")))
|
||||||
|
throw new Exception("Task database must be a singleton");
|
||||||
|
if (provider.GetRequiredService<DashboardOptions>().Authorization.Count() != 1)
|
||||||
|
throw new Exception("Dashboard authentication missing");
|
||||||
|
Console.WriteLine("PASS Hangfire configuration and shared registrations (no database connection)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
|||||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
|
||||||
if (args.ElementAtOrDefault(0) == "configuration") { ConfigurationChecks.Run(); return; }
|
if (args.ElementAtOrDefault(0) == "configuration") { ConfigurationChecks.Run(); return; }
|
||||||
|
if (args.ElementAtOrDefault(0) == "hangfire") { HangfireChecks.Run(); return; }
|
||||||
|
|
||||||
// Run each scenario in a fresh process: default ALC intentionally cannot unload plugins.
|
// Run each scenario in a fresh process: default ALC intentionally cannot unload plugins.
|
||||||
var finderType = typeof(ServiceCollectionExtensions).Assembly.GetType("MAX.Framework.DependencyInjection.StoreAppTypeFinder", true)!;
|
var finderType = typeof(ServiceCollectionExtensions).Assembly.GetType("MAX.Framework.DependencyInjection.StoreAppTypeFinder", true)!;
|
||||||
@@ -24,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");
|
||||||
@@ -42,21 +43,48 @@ 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";
|
||||||
core.Invoke(null, new object[] { host, plugins, new Dictionary<Type, object>() });
|
core.Invoke(null, new object[] { host, plugins, new Dictionary<Type, object>() });
|
||||||
|
if (host.Services.Any(d => d.ServiceType == typeof(Hangfire.JobStorage)))
|
||||||
|
throw new Exception("Plugins must not register shared Hangfire storage");
|
||||||
using var built = host.Build();
|
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");
|
||||||
foreach (var name in new[] { "OpenSearchService", "UserFavoritesIndexService", "RawDataStorage", "GoodsIndexJob", "GoodsService" })
|
foreach (var name in new[] { "OpenSearchService", "UserFavoritesIndexService", "RawDataStorage", "GoodsIndexJob", "GoodsViolationIndexJob", "GoodsService" })
|
||||||
{
|
{
|
||||||
var type = plugins.SelectMany(p => p.GetTypes()).Single(t => t.Name == name);
|
var type = plugins.SelectMany(p => p.GetTypes()).Single(t => t.Name == name);
|
||||||
using var scope = built.Services.CreateScope();
|
using var scope = built.Services.CreateScope();
|
||||||
|
|||||||
@@ -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,113 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Store.Modules.CloudProduct.Entities;
|
||||||
|
|
||||||
|
// 使用固定时间验证过期边界,无需等待或连接数据库。
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var cache = new ImageAnalysisCache { UpdatedAt = now, RecognitionResult = "[]" };
|
||||||
|
foreach (var days in new[] { 1, 2, 4, 8, 16, 16 })
|
||||||
|
{
|
||||||
|
cache.RecordExpiration(true, now);
|
||||||
|
if (cache.ExpiresAt != days * 24
|
||||||
|
|| cache.ReadFreshResult(now.AddDays(days).AddTicks(-1)) is not { Length: 0 }
|
||||||
|
|| cache.ReadFreshResult(now.AddDays(days)) != null)
|
||||||
|
throw new Exception("连续空识别结果的缓存期限错误。");
|
||||||
|
now = now.AddDays(days);
|
||||||
|
}
|
||||||
|
cache.RecognitionResult = """["文字"]""";
|
||||||
|
cache.RecordExpiration(false, now);
|
||||||
|
if (cache.ExpiresAt != 720
|
||||||
|
|| cache.ReadFreshResult(now.AddDays(30).AddTicks(-1)) is not ["文字"]
|
||||||
|
|| cache.ReadFreshResult(now.AddDays(30)) != null)
|
||||||
|
throw new Exception("非空识别结果应重置级别并缓存三十天。");
|
||||||
|
cache.RecognitionResult = "{}";
|
||||||
|
if (cache.ReadFreshResult(now) != null)
|
||||||
|
throw new Exception("旧对象格式应立即重新识别。");
|
||||||
|
cache.RecordExpiration(true, now.AddDays(30));
|
||||||
|
if (cache.ExpiresAt != 24)
|
||||||
|
throw new Exception("非空结果后的首次空结果应重新从一天开始。");
|
||||||
|
if (args.Contains("--cache-policy"))
|
||||||
|
{
|
||||||
|
Console.WriteLine("PASS cache expiration checks");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var config = JsonDocument.Parse(File.ReadAllText("Plugins/CloudProduct/appsettings.json"), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||||
|
const string tableQuery = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME";
|
||||||
|
const string columnsQuery = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' ORDER BY ORDINAL_POSITION";
|
||||||
|
if (args.Contains("--migrate-cache-expiration"))
|
||||||
|
{
|
||||||
|
// 运行时缓存使用 CloudProductConnection;历史初始化命令仍针对原专用库。
|
||||||
|
var cacheConnection = Environment.GetEnvironmentVariable("Plugins__CloudProduct__CloudProductConnection")
|
||||||
|
?? config.RootElement.GetProperty("CloudProductConnection").GetString();
|
||||||
|
using var cacheDb = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, cacheConnection).Build();
|
||||||
|
const string expirationTypeQuery = """
|
||||||
|
SELECT DATA_TYPE FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' AND COLUMN_NAME = 'ExpiresAt'
|
||||||
|
""";
|
||||||
|
var columns = await cacheDb.Ado.QueryAsync<string>(columnsQuery);
|
||||||
|
var expirationType = (await cacheDb.Ado.QueryAsync<string>(expirationTypeQuery)).SingleOrDefault();
|
||||||
|
Console.WriteLine($"Cache columns before migration: {string.Join(", ", columns)}; ExpiresAt: {expirationType ?? "absent"}");
|
||||||
|
if (!columns.Contains("RecognitionResult") || !columns.Contains("UpdatedAt"))
|
||||||
|
throw new InvalidOperationException("当前运行库缺少缓存表或基础列。");
|
||||||
|
if (expirationType is not (null or "int" or "datetime" or "timestamp"))
|
||||||
|
throw new InvalidOperationException("不支持的 ExpiresAt 字段类型。");
|
||||||
|
// 用临时列完成日期到时长的转换,回填完成后才一次性替换原列。
|
||||||
|
var hasDateColumn = expirationType is "datetime" or "timestamp";
|
||||||
|
var hoursColumn = hasDateColumn ? "ExpiresAtHours" : "ExpiresAt";
|
||||||
|
if (!columns.Contains(hoursColumn))
|
||||||
|
await cacheDb.Ado.ExecuteNonQueryAsync($"ALTER TABLE image_analysis_cache ADD {hoursColumn} INT NULL");
|
||||||
|
// 已有整数时长原样保留;旧日期列按原到期时间与更新时间的差值换算。
|
||||||
|
var initialHours = "IF(TRIM(RecognitionResult) = '[]', 24, 720)";
|
||||||
|
if (hasDateColumn)
|
||||||
|
initialHours = $"COALESCE(TIMESTAMPDIFF(HOUR, UpdatedAt, ExpiresAt), {initialHours})";
|
||||||
|
await cacheDb.Ado.ExecuteNonQueryAsync($"UPDATE image_analysis_cache SET {hoursColumn} = {initialHours} WHERE {hoursColumn} IS NULL");
|
||||||
|
if (hasDateColumn)
|
||||||
|
await cacheDb.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache DROP COLUMN ExpiresAt, CHANGE ExpiresAtHours ExpiresAt INT NOT NULL");
|
||||||
|
else if ((await cacheDb.Ado.QueryAsync<string>("SELECT IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'image_analysis_cache' AND COLUMN_NAME = 'ExpiresAt'")).Single() == "YES")
|
||||||
|
await cacheDb.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache MODIFY ExpiresAt INT NOT NULL");
|
||||||
|
|
||||||
|
var finalType = (await cacheDb.Ado.QueryAsync<string>(expirationTypeQuery)).Single();
|
||||||
|
var invalidRows = Convert.ToInt64(await cacheDb.Ado.ExecuteScalarAsync("""
|
||||||
|
SELECT COUNT(*) FROM image_analysis_cache
|
||||||
|
WHERE ExpiresAt IS NULL OR ExpiresAt NOT IN (24, 48, 96, 192, 384, 720)
|
||||||
|
"""));
|
||||||
|
if (finalType != "int" || invalidRows != 0)
|
||||||
|
throw new InvalidOperationException($"迁移校验失败,ExpiresAt 类型 {finalType},异常行数 {invalidRows}。");
|
||||||
|
if (columns.Contains("EmptyResultStreak"))
|
||||||
|
await cacheDb.Ado.ExecuteNonQueryAsync("ALTER TABLE image_analysis_cache DROP COLUMN EmptyResultStreak");
|
||||||
|
Console.WriteLine("PASS database migration: cache lifetime stored in ExpiresAt hours");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var connection = Environment.GetEnvironmentVariable("Plugins__CloudProduct__ProductInspection__ConnectionString")
|
||||||
|
?? config.RootElement.GetProperty("ProductInspection").GetProperty("ConnectionString").GetString();
|
||||||
|
using var db = new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection).Build();
|
||||||
|
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", "ExpiresAt" }))
|
||||||
|
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