更新Hangfire提取到 框架

This commit is contained in:
chfei committed 2026-11-03 12:17:49 +08:00
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>());
}
+5
View File
@@ -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" />
+9 -34
View File
@@ -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>
-6
View File
@@ -15,11 +15,5 @@
},
"Delete": {
"SoftDelete": true
},
"Hangfire": {
"Dashboard" :{
"User": "admin",
"Pass": "1234560"
}
}
}
+11 -2
View File
@@ -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
+2
View File
@@ -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();
+7
View File
@@ -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)");
}
}
+4 -1
View File
@@ -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();