diff --git a/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs
index a24ef14..70f2faf 100644
--- a/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs
+++ b/Core/MAX.Framework/DependencyInjection/HostBuilderExtensions.cs
@@ -1,14 +1,17 @@
using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration.Json;
using Microsoft.Extensions.DependencyInjection;
+using System.Reflection;
namespace MAX.Framework.DependencyInjection
{
///
- /// 宿主(Program.cs)入口:模块两阶段装配。
+ /// 宿主(Program.cs)入口:模块两阶段装配 + 模块自己的配置文件。
///
/// var builder = WebApplication.CreateBuilder(args);
- /// builder.AddStoreHub(); // 阶段一:服务注册(自动执行所有 IModule.AddModule)
+ /// builder.AddStoreHub(); // 阶段一:加载各模块配置文件 + 自动执行所有 IModule.AddModule
///
/// var app = builder.Build();
/// // ... 宿主自己的中间件、认证、端点 ...
@@ -20,12 +23,27 @@ namespace MAX.Framework.DependencyInjection
{
///
/// 在 上注册应用框架服务:
- /// 等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),
- /// 区别是会把配置与环境一起包装成 交给各模块。
+ ///
+ /// - 发现所有 ;
+ /// - 把每个模块声明的 并入宿主配置树(宿主 appsettings.json 不需要放模块的配置);
+ /// - 再执行 builder.Services.AddStoreHub(builder.Configuration, builder.Environment)。
+ ///
///
public static WebApplicationBuilder AddStoreHub(this WebApplicationBuilder builder)
{
- builder.Services.AddStoreHub(builder.Configuration, builder.Environment);
+ IEnumerable assemblies = ServiceCollectionExtensions.GetApplicationAssemblies();
+
+ // 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证后面两个阶段拿到的是同一个对象
+ Dictionary instances = [];
+ ModuleRegistry modules = ServiceCollectionExtensions.DiscoverModules(assemblies, instances);
+
+ // 必须在跑 AddModule 之前并入,模块注册时才读得到自己的配置节
+ foreach (IModule module in modules.Modules)
+ {
+ AddModuleConfigurationFile(builder.Configuration, module);
+ }
+
+ builder.Services.AddStoreHubCore(assemblies, modules, instances, builder.Configuration, builder.Environment);
return builder;
}
@@ -57,5 +75,51 @@ namespace MAX.Framework.DependencyInjection
return app;
}
+
+ ///
+ /// 把模块自己的配置文件并进宿主配置:插在 appsettings*.json 之后、环境变量/命令行之前,
+ /// 这样模块配置能覆盖宿主 appsettings.json,同时仍然允许用环境变量/命令行覆盖它。
+ ///
+ private static void AddModuleConfigurationFile(IConfigurationBuilder configuration, IModule module)
+ {
+ string? fileName = module.ConfigurationFile;
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return;
+ }
+
+ string path = Path.IsPathRooted(fileName)
+ ? fileName
+ : Path.Combine(AppContext.BaseDirectory, fileName);
+
+ JsonConfigurationSource source = new()
+ {
+ Path = path,
+ Optional = true, // 文件不存在时不报错(模块没配就是没有)
+ ReloadOnChange = true // 允许改配置不重启(与 appsettings.json 行为一致)
+ };
+ source.ResolveFileProvider();
+
+ IList sources = configuration.Sources;
+ int index = sources.Count;
+
+ // 找到最后一个 appsettings*.json,插到它后面
+ for (int i = sources.Count - 1; i >= 0; i--)
+ {
+ if (sources[i] is FileConfigurationSource file
+ && Path.GetFileName(file.Path).StartsWith("appsettings", StringComparison.OrdinalIgnoreCase))
+ {
+ index = i + 1;
+ break;
+ }
+ }
+
+ sources.Insert(index, source);
+
+ if (configuration is IConfigurationRoot root)
+ {
+ root.Reload();
+ }
+ }
}
}
diff --git a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs
index c8be9ad..cb16315 100644
--- a/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/Core/MAX.Framework/DependencyInjection/ServiceCollectionExtensions.cs
@@ -25,8 +25,8 @@ namespace MAX.Framework.DependencyInjection
];
///
- /// 兼容入口:只有服务集合,没有配置与环境(模块拿到的 是空配置)。
- /// 新代码请用 builder.AddStoreHub()。
+ /// 兼容入口:只有服务集合,没有配置与环境(模块拿到的 是空配置),
+ /// 也拿不到 IConfigurationBuilder,所以模块自己的配置文件不会加载。新代码请用 builder.AddStoreHub()。
///
public static IServiceCollection AddStoreHub(this IServiceCollection services)
{
@@ -49,6 +49,7 @@ namespace MAX.Framework.DependencyInjection
/// - 发现所有 ,按 降序执行 ,
/// 并把模块实例登记进容器,留给 app.UseStoreHubModules() 执行管道阶段。
///
+ /// 这个入口不加载模块自己的配置文件,需要模块配置请用 builder.AddStoreHub()。
///
public static IServiceCollection AddStoreHub(this IServiceCollection services, IConfiguration? configuration, IHostEnvironment? environment)
{
@@ -56,7 +57,22 @@ namespace MAX.Framework.DependencyInjection
// 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
Dictionary instances = [];
+ ModuleRegistry modules = DiscoverModules(assemblies, instances);
+ return services.AddStoreHubCore(assemblies, modules, instances, configuration, environment);
+ }
+
+ ///
+ /// 注册应用服务的公共实现:宿主入口(builder.AddStoreHub())与 入口共用。
+ ///
+ internal static IServiceCollection AddStoreHubCore(
+ this IServiceCollection services,
+ IEnumerable assemblies,
+ ModuleRegistry modules,
+ IDictionary instances,
+ IConfiguration? configuration,
+ IHostEnvironment? environment)
+ {
RegisterDependencyMarkers(services, assemblies);
RunStartups(services, assemblies, instances);
@@ -66,8 +82,10 @@ namespace MAX.Framework.DependencyInjection
configuration ?? new ConfigurationBuilder().Build(),
environment);
+ RunModules(modules, context);
+
// 注册表必须在 Build() 之前进容器,UseStoreHubModules() 才拿得到
- services.AddSingleton(RunModules(assemblies, instances, context));
+ services.AddSingleton(modules);
services.AddControllers(options =>
@@ -147,13 +165,19 @@ namespace MAX.Framework.DependencyInjection
}
///
- /// 发现所有 实现,按 降序执行服务注册阶段
+ /// 发现所有 实现,只创建实例,不执行任何阶段
///
- private static ModuleRegistry RunModules(IEnumerable assemblies, IDictionary instances, ModuleContext context)
+ internal static ModuleRegistry DiscoverModules(IEnumerable assemblies, IDictionary instances)
{
- List modules = [.. CreateInstances(assemblies, instances).OrderByDescending(m => m.Priority)];
+ return new ModuleRegistry([.. CreateInstances(assemblies, instances).OrderByDescending(m => m.Priority)]);
+ }
- foreach (IModule module in modules)
+ ///
+ /// 执行所有模块的服务注册阶段()
+ ///
+ private static void RunModules(ModuleRegistry modules, ModuleContext context)
+ {
+ foreach (IModule module in modules.Modules)
{
try
{
@@ -165,8 +189,6 @@ namespace MAX.Framework.DependencyInjection
throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败。", ex);
}
}
-
- return new ModuleRegistry(modules);
}
///
@@ -206,7 +228,10 @@ namespace MAX.Framework.DependencyInjection
}
}
- private static IEnumerable GetApplicationAssemblies()
+ ///
+ /// 应用程序集(模块、Startup 都从这里发现)
+ ///
+ internal static IEnumerable GetApplicationAssemblies()
{
// 使用 AppTypeFinder:扫描应用输出目录 + 已加载程序集,
return StoreHubAppTypeFinder.Current.GetAssemblies();
diff --git a/Core/MAX.Framework/Interfaces/IModule.cs b/Core/MAX.Framework/Interfaces/IModule.cs
index a14214a..0d02414 100644
--- a/Core/MAX.Framework/Interfaces/IModule.cs
+++ b/Core/MAX.Framework/Interfaces/IModule.cs
@@ -19,6 +19,16 @@ namespace MAX.Framework.Interfaces
///
int Priority => MAX.Core.Interfaces.IntPriority.Normal;
+ ///
+ /// 模块自己的配置文件(相对宿主输出目录 ),
+ /// 例如 "appsettings.CloudProduct.json";默认 null 表示该模块没有独立配置文件。
+ /// 该文件由 builder.AddStoreHub() 自动并入宿主配置树(插在 appsettings*.json 之后、环境变量/命令行之前),
+ /// 因此模块内的服务照常注入 IConfiguration 就能读到自己的配置节,模块之间互不干扰。
+ /// 注意:只有 builder.AddStoreHub() 入口能加载模块配置文件,
+ /// 直接用 builder.Services.AddStoreHub() 时拿不到 IConfigurationBuilder,模块配置不会生效。
+ ///
+ string? ConfigurationFile => null;
+
///
/// 服务注册阶段(builder.Build() 之前):注册本模块的服务、选项、后台任务类型。
/// 需要读配置时用 ,不要再去碰 builder。
diff --git a/Core/MAX.Framework/Interfaces/ModuleBase.cs b/Core/MAX.Framework/Interfaces/ModuleBase.cs
index 88a5e4b..af0a9e1 100644
--- a/Core/MAX.Framework/Interfaces/ModuleBase.cs
+++ b/Core/MAX.Framework/Interfaces/ModuleBase.cs
@@ -12,6 +12,9 @@ namespace MAX.Framework.Interfaces
///
public virtual int Priority => IntPriority.Normal;
+ ///
+ public virtual string? ConfigurationFile => null;
+
///
public abstract void AddModule(ModuleContext context);
diff --git a/Models/CloudProduct/CloudProductModule.cs b/Models/CloudProduct/CloudProductModule.cs
index 0963d8d..e0d7260 100644
--- a/Models/CloudProduct/CloudProductModule.cs
+++ b/Models/CloudProduct/CloudProductModule.cs
@@ -21,8 +21,16 @@ namespace Store.Modules.CloudProduct
///
public class CloudProductModule : ModuleBase
{
+ ///
+ /// 本模块自己的配置文件(源码里是 Models/CloudProduct/appsettings.json,
+ /// 构建时复制到宿主输出目录并改名成这个名字,避免与宿主的 appsettings.json 冲突)
+ ///
+ public const string ConfigurationFileName = "appsettings.CloudProduct.json";
+
public override int Priority => IntPriority.Normal;
+ public override string? ConfigurationFile => ConfigurationFileName;
+
///
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
///
@@ -31,6 +39,12 @@ namespace Store.Modules.CloudProduct
IServiceCollection services = context.Services;
IConfiguration configuration = context.Configuration;
+ // 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里,
+ // 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
+ RequireConfiguration(configuration, "Search:Url");
+ RequireConfiguration(configuration, "Search:IndexName");
+ RequireConfiguration(configuration, "RawData:Path");
+
// ===== Hangfire 后台定时任务(商品分词同步) =====
var hangfireConnection = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。");
@@ -85,5 +99,16 @@ namespace Store.Modules.CloudProduct
Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
}
}
+
+ ///
+ /// 校验模块配置项存在(值来自模块自己的 )
+ ///
+ private static void RequireConfiguration(IConfiguration configuration, string key)
+ {
+ if (string.IsNullOrWhiteSpace(configuration.GetValue(key)))
+ {
+ throw new InvalidOperationException($"缺少配置 {key},请检查模块配置文件 {ConfigurationFileName}。");
+ }
+ }
}
}
diff --git a/Models/CloudProduct/Store.Modules.CloudProduct.csproj b/Models/CloudProduct/Store.Modules.CloudProduct.csproj
index 4e98693..9e875dc 100644
--- a/Models/CloudProduct/Store.Modules.CloudProduct.csproj
+++ b/Models/CloudProduct/Store.Modules.CloudProduct.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,6 +11,12 @@
+
+
+
+
diff --git a/Models/CloudProduct/appsettings.json b/Models/CloudProduct/appsettings.json
new file mode 100644
index 0000000..733f8f3
--- /dev/null
+++ b/Models/CloudProduct/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "Search": {
+ "Url": "http://192.168.0.200:9200",
+ "IndexName": "goods_search"
+ },
+ "RawData": {
+ "Path": "E:\\GoodsRawData"
+ },
+ "Indexing": {
+ "Mode": "Background",
+ "Cron": "*/5 * * * *",
+ "BatchSize": 20000
+ },
+ "Delete": {
+ "SoftDelete": true
+ }
+}