diff --git a/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs
new file mode 100644
index 0000000..a24ef14
--- /dev/null
+++ b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs
@@ -0,0 +1,61 @@
+using MAX.Framework.Interfaces;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MAX.Framework.DependencyInjection
+{
+ ///
+ /// 宿主(Program.cs)入口:模块两阶段装配。
+ ///
+ /// var builder = WebApplication.CreateBuilder(args);
+ /// builder.AddStoreHub(); // 阶段一:服务注册(自动执行所有 IModule.AddModule)
+ ///
+ /// var app = builder.Build();
+ /// // ... 宿主自己的中间件、认证、端点 ...
+ /// app.UseStoreHubModules(); // 阶段二:管道装配(自动执行所有 IModule.UseModule)
+ /// app.Run();
+ ///
+ ///
+ public static class HostBuilderExtensions
+ {
+ ///
+ /// 在 上注册应用框架服务:
+ /// 等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),
+ /// 区别是会把配置与环境一起包装成 交给各模块。
+ ///
+ public static WebApplicationBuilder AddStoreHub(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddStoreHub(builder.Configuration, builder.Environment);
+ return builder;
+ }
+
+ ///
+ /// 执行所有模块的管道阶段():注册中间件、看板、Map 端点等。
+ /// 必须在 builder.Build() 之后、app.Run() 之前调用;
+ /// 建议放在 UseAuthorization() 之后、MapControllers() 之前。
+ ///
+ public static WebApplication UseStoreHubModules(this WebApplication app)
+ {
+ // 宿主没调用过 AddStoreHub() 时容器里没有注册表,直接返回即可
+ ModuleRegistry? registry = app.Services.GetService();
+ if (registry is null)
+ {
+ return app;
+ }
+
+ foreach (IModule module in registry.Modules)
+ {
+ try
+ {
+ module.UseModule(app);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 UseModule 执行失败。", ex);
+ }
+ }
+
+ return app;
+ }
+ }
+}
diff --git a/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs b/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs
new file mode 100644
index 0000000..25aef10
--- /dev/null
+++ b/Core/MAX.Framework/DependencyInjection/ModuleRegistry.cs
@@ -0,0 +1,20 @@
+using MAX.Framework.Interfaces;
+
+namespace MAX.Framework.DependencyInjection
+{
+ ///
+ /// 模块注册表:保存 AddStoreHub() 阶段发现(并按优先级排序)的模块实例。
+ /// 它被注册进容器,好让 app.UseStoreHubModules() 在 builder.Build() 之后拿到
+ /// 「同一批实例」继续执行管道阶段,而不是重新反射创建一遍。
+ ///
+ internal sealed class ModuleRegistry
+ {
+ public ModuleRegistry(IReadOnlyList modules)
+ {
+ Modules = modules;
+ }
+
+ /// 按优先级降序排列的模块实例
+ public IReadOnlyList Modules { get; }
+ }
+}
diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs
index a4095d5..c8be9ad 100644
--- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs
@@ -1,10 +1,12 @@
-using MAX.Core.DependencyInjection;
+using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using MAX.Framework.Commons;
using MAX.Framework.Dtos;
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using System.Diagnostics;
using System.Reflection;
@@ -23,18 +25,49 @@ namespace MAX.Framework.DependencyInjection
];
///
- /// 注册应用服务:
- ///
- /// - 自动注册所有实现 / / 的类型;
- /// - 发现所有 ,按 降序执行 Initialize + Register。
- ///
+ /// 兼容入口:只有服务集合,没有配置与环境(模块拿到的 是空配置)。
+ /// 新代码请用 builder.AddStoreHub()。
///
public static IServiceCollection AddStoreHub(this IServiceCollection services)
+ {
+ return services.AddStoreHub(null, null);
+ }
+
+ ///
+ /// 带配置的注册入口:builder.Services.AddStoreHub(builder.Configuration)
+ ///
+ public static IServiceCollection AddStoreHub(this IServiceCollection services, IConfiguration? configuration)
+ {
+ return services.AddStoreHub(configuration, null);
+ }
+
+ ///
+ /// 注册应用服务:
+ ///
+ /// - 自动注册所有实现 / / 的类型;
+ /// - 发现所有 ,按 降序执行 Initialize + Register;
+ /// - 发现所有 ,按 降序执行 ,
+ /// 并把模块实例登记进容器,留给 app.UseStoreHubModules() 执行管道阶段。
+ ///
+ ///
+ public static IServiceCollection AddStoreHub(this IServiceCollection services, IConfiguration? configuration, IHostEnvironment? environment)
{
IEnumerable assemblies = GetApplicationAssemblies();
+
+ // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
+ Dictionary instances = [];
+
RegisterDependencyMarkers(services, assemblies);
- RunStartups(services, assemblies);
- RunModules(services, assemblies);
+ RunStartups(services, assemblies, instances);
+
+ ModuleContext context = new(
+ services,
+ new ServiceRegistrarProxy(services),
+ configuration ?? new ConfigurationBuilder().Build(),
+ environment);
+
+ // 注册表必须在 Build() 之前进容器,UseStoreHubModules() 才拿得到
+ services.AddSingleton(RunModules(assemblies, instances, context));
services.AddControllers(options =>
@@ -102,28 +135,55 @@ namespace MAX.Framework.DependencyInjection
///
/// 发现所有 实现,按 降序执行
///
- private static void RunStartups(IServiceCollection services, IEnumerable assemblies)
+ private static void RunStartups(IServiceCollection services, IEnumerable assemblies, IDictionary instances)
{
IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
- foreach (IStartup startup in GetConcreteTypes(assemblies)
- .Where(t => typeof(IStartup).IsAssignableFrom(t))
- .Select(t => (IStartup)Activator.CreateInstance(t, true)!)
- .OrderByDescending(s => s.Priority))
+ foreach (IStartup startup in CreateInstances(assemblies, instances).OrderByDescending(s => s.Priority))
{
startup.Initialize();
startup.Register(registrar);
}
}
- private static void RunModules(IServiceCollection services, IEnumerable assemblies)
- {
- IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
- foreach (IModule startup in GetConcreteTypes(assemblies)
- .Where(t => typeof(IModule).IsAssignableFrom(t))
- .Select(t => (IModule)Activator.CreateInstance(t, true)!))
+ ///
+ /// 发现所有 实现,按 降序执行服务注册阶段
+ ///
+ private static ModuleRegistry RunModules(IEnumerable assemblies, IDictionary instances, ModuleContext context)
+ {
+ List modules = [.. CreateInstances(assemblies, instances).OrderByDescending(m => m.Priority)];
+
+ foreach (IModule module in modules)
{
- startup.AddModule(services);
+ try
+ {
+ module.AddModule(context);
+ }
+ catch (Exception ex)
+ {
+ // 带上模块名,避免只看到一个没有上下文的异常
+ throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败。", ex);
+ }
+ }
+
+ return new ModuleRegistry(modules);
+ }
+
+ ///
+ /// 反射创建(或复用)所有实现 的具体类型实例
+ ///
+ private static IEnumerable CreateInstances(IEnumerable assemblies, IDictionary instances) where T : class
+ {
+ foreach (Type type in GetConcreteTypes(assemblies).Where(t => typeof(T).IsAssignableFrom(t)))
+ {
+ if (!instances.TryGetValue(type, out object? instance))
+ {
+ instance = Activator.CreateInstance(type, true)
+ ?? throw new InvalidOperationException($"无法创建 {type.FullName} 的实例(需要有参数量为 0 的构造函数)。");
+ instances[type] = instance;
+ }
+
+ yield return (T)instance;
}
}
diff --git a/Core/MAX.Framework/Interfaces/IModule.cs b/Core/MAX.Framework/Interfaces/IModule.cs
index 2a14d72..a14214a 100644
--- a/Core/MAX.Framework/Interfaces/IModule.cs
+++ b/Core/MAX.Framework/Interfaces/IModule.cs
@@ -1,12 +1,37 @@
-using Microsoft.Extensions.DependencyInjection;
-using System;
-using System.Collections.Generic;
-using System.Text;
+using Microsoft.AspNetCore.Builder;
namespace MAX.Framework.Interfaces
{
+ ///
+ /// 业务模块:把「服务注册」和「管道装配」两件事都交给模块自己,
+ /// 由宿主的两行代码统一驱动(模块不需要出现在 Program.cs 里):
+ ///
+ /// - builder.AddStoreHub() → 自动发现所有模块并执行 (服务注册阶段,Build() 之前);
+ /// - app.UseStoreHubModules() → 自动执行 (管道阶段,Build() 之后、Run() 之前)。
+ ///
+ /// 模块实例由反射创建,因此实现类必须有无参构造函数。
+ ///
public interface IModule
{
- void AddModule(IServiceCollection services);
+ ///
+ /// 模块执行顺序(数值越大越先执行),默认 。
+ /// 两个阶段都按这个顺序执行。
+ ///
+ int Priority => MAX.Core.Interfaces.IntPriority.Normal;
+
+ ///
+ /// 服务注册阶段(builder.Build() 之前):注册本模块的服务、选项、后台任务类型。
+ /// 需要读配置时用 ,不要再去碰 builder。
+ ///
+ void AddModule(ModuleContext context);
+
+ ///
+ /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):
+ /// 注册中间件(如 Hangfire 看板)、Map 端点、读取 解析服务、注册启动任务等。
+ /// 不需要管道装配的模块可以不实现(默认空实现)。
+ ///
+ void UseModule(WebApplication app)
+ {
+ }
}
}
diff --git a/Core/MAX.Framework/Interfaces/ModuleBase.cs b/Core/MAX.Framework/Interfaces/ModuleBase.cs
new file mode 100644
index 0000000..88a5e4b
--- /dev/null
+++ b/Core/MAX.Framework/Interfaces/ModuleBase.cs
@@ -0,0 +1,23 @@
+using MAX.Core.Interfaces;
+using Microsoft.AspNetCore.Builder;
+
+namespace MAX.Framework.Interfaces
+{
+ ///
+ /// 模块基类:给出 与 的默认实现,
+ /// 模块只需重写自己真正关心的阶段(只有服务注册时只写 )。
+ ///
+ public abstract class ModuleBase : IModule
+ {
+ ///
+ public virtual int Priority => IntPriority.Normal;
+
+ ///
+ public abstract void AddModule(ModuleContext context);
+
+ ///
+ public virtual void UseModule(WebApplication app)
+ {
+ }
+ }
+}
diff --git a/Core/MAX.Framework/Interfaces/ModuleContext.cs b/Core/MAX.Framework/Interfaces/ModuleContext.cs
new file mode 100644
index 0000000..da6bfa3
--- /dev/null
+++ b/Core/MAX.Framework/Interfaces/ModuleContext.cs
@@ -0,0 +1,46 @@
+using MAX.Core.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MAX.Framework.Interfaces
+{
+ ///
+ /// 模块上下文:模块在「服务注册阶段」能拿到的全部宿主信息。
+ /// 之所以包成一个对象,是为了以后要再补宿主的其他东西(日志、配置节、HostApplicationBuilder 等)时不用改接口签名。
+ ///
+ public sealed class ModuleContext
+ {
+ public ModuleContext(
+ IServiceCollection services,
+ IServiceRegistrar registrar,
+ IConfiguration configuration,
+ IHostEnvironment? environment = null)
+ {
+ Services = services;
+ Registrar = registrar;
+ Configuration = configuration;
+ Environment = environment;
+ }
+
+ ///
+ /// 服务集合(Microsoft.Extensions.DependencyInjection),等价于宿主里的 builder.Services
+ ///
+ public IServiceCollection Services { get; }
+
+ ///
+ /// 容器无关的注册器,等价于 里拿到的那个,便于以后换容器
+ ///
+ public IServiceRegistrar Registrar { get; }
+
+ ///
+ /// 应用配置,等价于宿主的 builder.Configuration(永不为 null;走旧的无参入口时是空配置)
+ ///
+ public IConfiguration Configuration { get; }
+
+ ///
+ /// 宿主环境,等价于宿主的 builder.Environment;走旧的无参入口时为 null
+ ///
+ public IHostEnvironment? Environment { get; }
+ }
+}
diff --git a/Models/CloudProduct/CloudProductModule.cs b/Models/CloudProduct/CloudProductModule.cs
new file mode 100644
index 0000000..0963d8d
--- /dev/null
+++ b/Models/CloudProduct/CloudProductModule.cs
@@ -0,0 +1,89 @@
+using Hangfire;
+using Hangfire.MySql;
+using HangfireBasicAuthenticationFilter;
+using MAX.Core.Interfaces;
+using MAX.Framework.Interfaces;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using MySql.Data.MySqlClient;
+using Store.Modules.CloudProduct.Services;
+
+namespace Store.Modules.CloudProduct
+{
+ ///
+ /// CloudProduct 模块的模块化入口():Hangfire 后台任务(商品分词同步)+ Hangfire 看板。
+ /// 与 (只做 IStartup 的 Initialize/Register)分开,是因为模块注册需要配置和 app:
+ ///
+ /// - builder.AddStoreHub() 执行 (注册 Hangfire 服务,此时只能拿到容器和配置);
+ /// - app.UseStoreHubModules() 执行 (挂看板中间件 + 注册定时任务,此时才有 app)。
+ ///
+ ///
+ public class CloudProductModule : ModuleBase
+ {
+ public override int Priority => IntPriority.Normal;
+
+ ///
+ /// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
+ ///
+ public override void AddModule(ModuleContext context)
+ {
+ IServiceCollection services = context.Services;
+ IConfiguration configuration = context.Configuration;
+
+ // ===== Hangfire 后台定时任务(商品分词同步) =====
+ var hangfireConnection = configuration.GetConnectionString("DefaultConnection")
+ ?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。");
+
+ // AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量
+ var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection)
+ {
+ AllowUserVariables = true
+ };
+
+ services.AddHangfire(config => config
+ .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
+ .UseSimpleAssemblyNameTypeSerializer()
+ .UseRecommendedSerializerSettings()
+ .UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
+ services.AddHangfireServer();
+ services.AddTransient();
+ }
+
+ ///
+ /// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
+ ///
+ public override void UseModule(WebApplication app)
+ {
+ try
+ {
+ // Hangfire 后台任务看板(默认地址 /hangfire)
+ app.UseHangfireDashboard("/hangfire", new DashboardOptions
+ {
+ Authorization = new[]
+ {
+ new HangfireCustomBasicAuthenticationFilter(){
+ User = app.Configuration.GetValue("Hangfire:Dashboard:User") ?? "admin",
+ Pass = app.Configuration.GetValue("Hangfire:Dashboard:Pass") ?? "1234560"
+ }
+ }
+ });
+
+ // 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
+ var cron = app.Configuration.GetValue("Indexing:Cron") ?? "*/5 * * * *";
+
+ // 用容器里的 IRecurringJobManager,而不是静态 RecurringJob:
+ // AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。
+ app.Services.GetRequiredService().AddOrUpdate(
+ "goods-index-pending",
+ job => job.ProcessPendingAsync(),
+ cron);
+ }
+ catch (Exception ex)
+ {
+ // 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配
+ Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
+ }
+ }
+ }
+}
diff --git a/Models/CloudProduct/Services/GoodsIndexJob.cs b/Models/CloudProduct/Services/GoodsIndexJob.cs
new file mode 100644
index 0000000..9fbe971
--- /dev/null
+++ b/Models/CloudProduct/Services/GoodsIndexJob.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Store.Modules.CloudProduct.Services
+{
+ ///
+ /// Hangfire 定时任务:把数据库中未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引。
+ ///
+ public class GoodsIndexJob
+ {
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+
+ public GoodsIndexJob(IServiceScopeFactory scopeFactory, IConfiguration configuration,ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ public async Task ProcessPendingAsync()
+ {
+ var batchSize = _configuration.GetValue("Indexing:BatchSize") ?? 1000;
+ using var scope = _scopeFactory.CreateScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ var count = await service.IndexPendingGoodsAsync(batchSize);
+ _logger.LogInformation($"[Hangfire] 商品分词定时任务完成,本次同步 {count} 条");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Models/CloudProduct/Startup.cs b/Models/CloudProduct/Startup.cs
index 35aa28c..3eb8ccf 100644
--- a/Models/CloudProduct/Startup.cs
+++ b/Models/CloudProduct/Startup.cs
@@ -1,28 +1,23 @@
-using MAX.Core.DependencyInjection;
+using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
-using MAX.Framework.Interfaces;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
namespace Store.Modules.CloudProduct
{
- public class Startup : IStartup, IModule
+ ///
+ /// CloudProduct 模块的启动器():由 AddStoreHub() 自动发现,
+ /// 按 降序执行 + 。
+ /// 需要配置或管道装配(app)的部分不在这里,见 。
+ ///
+ public class Startup : IStartup
{
public int Priority => IntPriority.Normal;
- public void AddModule(IServiceCollection services)
- {
- services.AddControllers();
- }
-
public void Initialize()
{
-
}
public void Register(IServiceRegistrar registrar)
{
-
}
}
}
diff --git a/Models/CloudProduct/Store.Modules.CloudProduct.csproj b/Models/CloudProduct/Store.Modules.CloudProduct.csproj
index 834fc3d..4e98693 100644
--- a/Models/CloudProduct/Store.Modules.CloudProduct.csproj
+++ b/Models/CloudProduct/Store.Modules.CloudProduct.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -12,6 +12,9 @@
+
+
+
diff --git a/Store.API/Program.cs b/Store.API/Program.cs
index 42f37d1..b716fe9 100644
--- a/Store.API/Program.cs
+++ b/Store.API/Program.cs
@@ -1,4 +1,4 @@
-using Asp.Versioning;
+using Asp.Versioning;
using MAX.Framework.DependencyInjection;
using Scalar.AspNetCore;
@@ -8,8 +8,9 @@ public partial class Program
{
var builder = WebApplication.CreateBuilder(args);
- // IOC 注册:自动注册 ISingletonDependency / IScopedDependency / ITransientDependency,并执行所有 IStartup(实现见 StoreHub.Framework)
- builder.Services.AddStoreHub();
+ // IOC 注册:自动注册 ISingletonDependency / IScopedDependency / ITransientDependency,并执行所有 IStartup / IModule.AddModule
+ // 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块)
+ builder.AddStoreHub();
builder.Services.AddFreeSqlDB();
// JWT 验证:注册 JwtBearer 认证 + 令牌服务 + OpenAPI Bearer 安全方案(配置节 Jwt)
builder.Services.AddStoreHubJwt(builder.Configuration);
@@ -62,6 +63,9 @@ public partial class Program
app.UseAuthorization();
+ // 模块管道装配阶段:执行所有 IModule.UseModule(Hangfire 看板、定时任务等),必须在 Run() 之前
+ app.UseStoreHubModules();
+
app.MapControllers();
app.Run();