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();