diff --git a/Core/MAX.Framework/DependencyInjection/HangfireServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/HangfireServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3242496
--- /dev/null
+++ b/Core/MAX.Framework/DependencyInjection/HangfireServiceCollectionExtensions.cs
@@ -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;
+
+/// 宿主共享的后台任务存储、执行服务与看板入口。
+public static class HangfireServiceCollectionExtensions
+{
+ /// 从宿主配置注册 Hangfire;任务数据库由独立的 FreeSql 单例管理。
+ 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("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("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;
+ }
+
+ /// 在宿主认证、授权中间件之后挂载共享看板。
+ public static IApplicationBuilder UseStoreHangfire(this IApplicationBuilder app)
+ => app.UseHangfireDashboard("/hangfire", app.ApplicationServices.GetRequiredService());
+}
diff --git a/Core/MAX.Framework/MAX.Framework.csproj b/Core/MAX.Framework/MAX.Framework.csproj
index 9230fc0..6648e22 100644
--- a/Core/MAX.Framework/MAX.Framework.csproj
+++ b/Core/MAX.Framework/MAX.Framework.csproj
@@ -11,6 +11,11 @@
+
+
+
+
+
diff --git a/Plugins/CloudProduct/CloudProductPlugin.cs b/Plugins/CloudProduct/CloudProductPlugin.cs
index c5496f2..35d3f7c 100644
--- a/Plugins/CloudProduct/CloudProductPlugin.cs
+++ b/Plugins/CloudProduct/CloudProductPlugin.cs
@@ -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("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("CloudProductConnection"), new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
- services.AddHangfireServer();
+ // 业务连接独立于宿主的 Hangfire 存储。
+ var connection = RequireConfiguration("CloudProductConnection");
+ var connectionStringBuilder = new MySqlConnectionStringBuilder(connection)
+ {
+ AllowUserVariables = true
+ };
services.AddTransient();
services.AddTransient();
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("Hangfire:Dashboard:User") ?? "admin",
- Pass = Configuration.GetValue("Hangfire:Dashboard:Pass") ?? "1234560"
- }
- }
- });
-
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
var cron = Configuration.GetValue("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);
}
}
}
-}
\ No newline at end of file
+}
diff --git a/Plugins/CloudProduct/Services/GoodsViolationIndexJob.cs b/Plugins/CloudProduct/Services/GoodsViolationIndexJob.cs
index f7d9959..4b67a42 100644
--- a/Plugins/CloudProduct/Services/GoodsViolationIndexJob.cs
+++ b/Plugins/CloudProduct/Services/GoodsViolationIndexJob.cs
@@ -2,10 +2,19 @@ using Microsoft.Extensions.DependencyInjection;
namespace Store.Modules.CloudProduct.Services;
+///
+/// 违规商品搜索索引清理任务,由 CloudProductPlugin 注册为每分钟执行一次。
+/// 每批处理最多 1000 个尚未完成索引删除的违规商品,循环处理至无待处理记录。
+///
public class GoodsViolationIndexJob(IServiceScopeFactory scopeFactory)
{
+ ///
+ /// 删除待处理商品的 OpenSearch 索引,成功后将违规记录的 IsIndexDeleted 标记为 true。
+ /// 失败时异常交由 Hangfire 处理,尚未标记完成的记录留待后续执行继续处理。
+ ///
public async Task ProcessPendingAsync()
{
+ // 为本次任务创建独立作用域,执行结束后释放业务服务及其作用域依赖。
using var scope = scopeFactory.CreateScope();
await scope.ServiceProvider.GetRequiredService()
.DeleteViolationIndexesAsync();
diff --git a/Plugins/CloudProduct/Store.CloudProduct.csproj b/Plugins/CloudProduct/Store.CloudProduct.csproj
index 27459a0..590a7d5 100644
--- a/Plugins/CloudProduct/Store.CloudProduct.csproj
+++ b/Plugins/CloudProduct/Store.CloudProduct.csproj
@@ -23,12 +23,7 @@
-
-
-
-
-
diff --git a/Plugins/CloudProduct/appsettings.json b/Plugins/CloudProduct/appsettings.json
index 6d24df0..2dc4888 100644
--- a/Plugins/CloudProduct/appsettings.json
+++ b/Plugins/CloudProduct/appsettings.json
@@ -15,11 +15,5 @@
},
"Delete": {
"SoftDelete": true
- },
- "Hangfire": {
- "Dashboard" :{
- "User": "admin",
- "Pass": "1234560"
- }
}
}
diff --git a/Plugins/README.md b/Plugins/README.md
index 77da6cc..f44ea7c 100644
--- a/Plugins/README.md
+++ b/Plugins/README.md
@@ -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
diff --git a/Store.API/Program.cs b/Store.API/Program.cs
index 8c34944..3418c31 100644
--- a/Store.API/Program.cs
+++ b/Store.API/Program.cs
@@ -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();
diff --git a/Store.API/appsettings.json b/Store.API/appsettings.json
index d53c4ab..404aa98 100644
--- a/Store.API/appsettings.json
+++ b/Store.API/appsettings.json
@@ -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"
},
diff --git a/Tests/PluginLoadingSmoke/HangfireChecks.cs b/Tests/PluginLoadingSmoke/HangfireChecks.cs
new file mode 100644
index 0000000..9d9e820
--- /dev/null
+++ b/Tests/PluginLoadingSmoke/HangfireChecks.cs
@@ -0,0 +1,49 @@
+using Hangfire;
+using MAX.Framework.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+/// 不连接数据库,检查宿主配置边界及共享服务注册。
+internal static class HangfireChecks
+{
+ public static void Run()
+ {
+ var settings = new Dictionary
+ {
+ ["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(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("HangfireConnection");
+ if (!ReferenceEquals(database, provider.GetRequiredKeyedService("HangfireConnection")))
+ throw new Exception("Task database must be a singleton");
+ if (provider.GetRequiredService().Authorization.Count() != 1)
+ throw new Exception("Dashboard authentication missing");
+ Console.WriteLine("PASS Hangfire configuration and shared registrations (no database connection)");
+ }
+}
diff --git a/Tests/PluginLoadingSmoke/Program.cs b/Tests/PluginLoadingSmoke/Program.cs
index b83b031..bbb93a9 100644
--- a/Tests/PluginLoadingSmoke/Program.cs
+++ b/Tests/PluginLoadingSmoke/Program.cs
@@ -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() });
+ 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("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("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();