更新Hangfire提取到 框架
This commit is contained in:
1 parent
33ef90a5cc
commit
7c29e57623
11 files changed
+151
-48
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>());
|
||||
}
|
||||
@@ -11,6 +11,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" 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.Provider.MySql" Version="3.5.311" />
|
||||
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using MAX.Plugins;
|
||||
using Hangfire;
|
||||
using Hangfire.MySql;
|
||||
using HangfireBasicAuthenticationFilter;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -16,28 +14,17 @@ namespace Store.Modules.CloudProduct
|
||||
public override string Id => "CloudProduct";
|
||||
protected override void RegisterServices(IServiceCollection services)
|
||||
{
|
||||
var configuration = Context.Configuration;
|
||||
RequireConfiguration("Search:Url");
|
||||
RequireConfiguration("Search:IndexName");
|
||||
RequireConfiguration("Search:FavoritesIndexName");
|
||||
RequireConfiguration("RawData:Path");
|
||||
|
||||
// ===== Hangfire 后台定时任务(商品分词同步) =====
|
||||
var hangfireConnection = configuration.GetValue<string>("CloudProductConnection")
|
||||
?? throw new InvalidOperationException("缺少配置 CloudProductConnection,CloudProduct 模块的 Hangfire 无法启动。");
|
||||
|
||||
// 存储中的建表/查询语句依赖用户变量;与业务仓储共用 FreeSql。
|
||||
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();
|
||||
// 业务连接独立于宿主的 Hangfire 存储。
|
||||
var connection = RequireConfiguration("CloudProductConnection");
|
||||
var connectionStringBuilder = new MySqlConnectionStringBuilder(connection)
|
||||
{
|
||||
AllowUserVariables = true
|
||||
};
|
||||
services.AddTransient<GoodsIndexJob>();
|
||||
services.AddTransient<GoodsViolationIndexJob>();
|
||||
services.AddAuthorization(options => options.AddPolicy("CloudGoodsViolationWrite",
|
||||
@@ -57,18 +44,6 @@ namespace Store.Modules.CloudProduct
|
||||
try
|
||||
{
|
||||
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 分词索引
|
||||
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
|
||||
|
||||
@@ -83,11 +58,11 @@ namespace Store.Modules.CloudProduct
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配
|
||||
Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
|
||||
// 定时任务注册失败时记录错误,数据库恢复后重启以重新注册。
|
||||
Console.WriteLine("[Hangfire] CloudProduct 定时任务注册失败: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,19 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 违规商品搜索索引清理任务,由 CloudProductPlugin 注册为每分钟执行一次。
|
||||
/// 每批处理最多 1000 个尚未完成索引删除的违规商品,循环处理至无待处理记录。
|
||||
/// </summary>
|
||||
public class GoodsViolationIndexJob(IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
/// <summary>
|
||||
/// 删除待处理商品的 OpenSearch 索引,成功后将违规记录的 IsIndexDeleted 标记为 true。
|
||||
/// 失败时异常交由 Hangfire 处理,尚未标记完成的记录留待后续执行继续处理。
|
||||
/// </summary>
|
||||
public async Task ProcessPendingAsync()
|
||||
{
|
||||
// 为本次任务创建独立作用域,执行结束后释放业务服务及其作用域依赖。
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<GoodsViolationService>()
|
||||
.DeleteViolationIndexesAsync();
|
||||
|
||||
@@ -23,12 +23,7 @@
|
||||
</None>
|
||||
</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" />
|
||||
<!-- 覆盖 Hangfire 的旧传递依赖,修复 CVE-2024-21907。 -->
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -15,11 +15,5 @@
|
||||
},
|
||||
"Delete": {
|
||||
"SoftDelete": true
|
||||
},
|
||||
"Hangfire": {
|
||||
"Dashboard" :{
|
||||
"User": "admin",
|
||||
"Pass": "1234560"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -66,8 +66,16 @@ public ExampleService([FromKeyedServices("CloudProduct")] PluginContext context)
|
||||
}
|
||||
```
|
||||
|
||||
数据库、Hangfire、OpenSearch 保留在各自插件内;认证、日志等仍由宿主管理。
|
||||
CloudProduct 的搜索启动检查使用 BackgroundService,网络错误记录日志,不阻断 HTTP 启动;Hangfire 仍按既有配置运行。
|
||||
业务数据库和 OpenSearch 保留在各自插件内;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
|
||||
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/Store.ImageCache" image
|
||||
dotnet run --project Tests/PluginLoadingSmoke -- <空目录或不存在目录> empty
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class Program
|
||||
// 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块)
|
||||
builder.AddStore();
|
||||
builder.Services.AddFreeSqlDB();
|
||||
builder.Services.AddStoreHangfire(builder.Configuration);
|
||||
// 数据库 Token 认证,配置节 TokenAuth
|
||||
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
|
||||
// Add services to the container.
|
||||
@@ -78,6 +79,7 @@ public partial class Program
|
||||
app.UseAuthentication();
|
||||
|
||||
app.UseAuthorization();
|
||||
app.UseStoreHangfire();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
"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": {
|
||||
"Path": "Plugins"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
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.
|
||||
var finderType = typeof(ServiceCollectionExtensions).Assembly.GetType("MAX.Framework.DependencyInjection.StoreAppTypeFinder", true)!;
|
||||
@@ -46,6 +47,8 @@ if (mode is "image" or "plugins")
|
||||
host.Configuration["Search:Url"] = "https://host.invalid";
|
||||
host.Configuration["ImageCache:ConnectionString"] = "host-only";
|
||||
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();
|
||||
var image = built.Services.GetRequiredKeyedService<PluginContext>("ImageCache");
|
||||
if (image.Configuration["ImageCache:ConnectionString"] is null or "host-only") throw new Exception("Image config isolation failed");
|
||||
@@ -56,7 +59,7 @@ if (mode is "image" or "plugins")
|
||||
var cloud = built.Services.GetRequiredKeyedService<PluginContext>("CloudProduct");
|
||||
if (cloud.Configuration["Search:Url"] is null or "https://host.invalid") throw new Exception("Cloud config isolation failed");
|
||||
if (host.Configuration["Search:Url"] != "https://host.invalid") throw new Exception("Cloud modified host configuration");
|
||||
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);
|
||||
using var scope = built.Services.CreateScope();
|
||||
|
||||
Reference in new issue
Block a user