修改 Startup 加载 和Module 加载

This commit is contained in:
chfei committed 2026-09-23 18:47:54 +08:00
1 parent c732ecb1d2
commit c7fb447034
17 files changed
+462 -383

No files matched your search

+27 -15
View File
@@ -6,8 +6,10 @@ using MAX.Framework.Interfaces;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MySql.Data.MySqlClient;
using Store.Modules.CloudProduct.Services;
using Microsoft.Extensions.Configuration;
namespace Store.Modules.CloudProduct
{
@@ -19,7 +21,7 @@ namespace Store.Modules.CloudProduct
/// <item><c>app.UseStoreHubModules()</c> 执行 <see cref="UseModule"/>(挂看板中间件 + 注册定时任务,此时才有 app)。</item>
/// </list>
/// </summary>
public class CloudProductModule : ModuleBase
public class CloudProductModule : IModule
{
/// <summary>
/// 本模块自己的配置文件(源码里是 Models/CloudProduct/appsettings.json,
@@ -27,23 +29,31 @@ namespace Store.Modules.CloudProduct
/// </summary>
public const string ConfigurationFileName = "appsettings.CloudProduct.json";
public override int Priority => IntPriority.Normal;
public int Priority => IntPriority.Normal;
public override string? ConfigurationFile => ConfigurationFileName;
/// <summary>
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
/// </summary>
public override void AddModule(ModuleContext context)
public void AddModule(IHostApplicationBuilder host)
{
IServiceCollection services = context.Services;
IConfiguration configuration = context.Configuration;
IServiceCollection services = host.Services;
IConfigurationManager configuration = host.Configuration;
// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里,
// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
if (!string.IsNullOrWhiteSpace(ConfigurationFileName))
{
string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName);
configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
}
//// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里,
//// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
RequireConfiguration(configuration, "Search:Url");
RequireConfiguration(configuration, "Search:IndexName");
RequireConfiguration(configuration, "RawData:Path");
//RequireConfiguration(configuration, "Search:IndexName");
//RequireConfiguration(configuration, "RawData:Path");
// ===== Hangfire 后台定时任务(商品分词同步) =====
var hangfireConnection = configuration.GetConnectionString("DefaultConnection")
@@ -62,33 +72,35 @@ namespace Store.Modules.CloudProduct
.UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
services.AddHangfireServer();
services.AddTransient<GoodsIndexJob>();
}
/// <summary>
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
/// </summary>
public override void UseModule(WebApplication app)
public void UseModule(IApplicationBuilder app)
{
try
{
var Configuration = app.ApplicationServices.GetService<IConfiguration>()!;
// Hangfire 后台任务看板(默认地址 /hangfire)
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[]
{
new HangfireCustomBasicAuthenticationFilter(){
User = app.Configuration.GetValue<string>("Hangfire:Dashboard:User") ?? "admin",
Pass = app.Configuration.GetValue<string>("Hangfire:Dashboard:Pass") ?? "1234560"
User = Configuration.GetValue<string>("Hangfire:Dashboard:User") ?? "admin",
Pass = Configuration.GetValue<string>("Hangfire:Dashboard:Pass") ?? "1234560"
}
}
});
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
var cron = app.Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
// 用容器里的 IRecurringJobManager,而不是静态 RecurringJob:
// AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。
app.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<GoodsIndexJob>(
app.ApplicationServices.GetRequiredService<IRecurringJobManager>().AddOrUpdate<GoodsIndexJob>(
"goods-index-pending",
job => job.ProcessPendingAsync(),
cron);
@@ -246,6 +246,65 @@ namespace Store.Modules.CloudProduct.Controllers
return await _searchService.GetStatusAsync();
}
/// <summary>
/// 检查搜索索引是否存在。
/// </summary>
[HttpGet]
public async Task<bool> IndexExists()
{
return await _searchService.IndexExistsAsync();
}
/// <summary>
/// 检查索引映射(分析器)是否符合当前服务要求。
/// </summary>
[HttpGet]
public async Task<bool> IndexMappingHealthy()
{
return await _searchService.IsIndexMappingHealthyAsync();
}
/// <summary>
/// 创建搜索索引(含 IK 分词映射,已存在且映射正常时跳过)。
/// </summary>
[HttpPost]
public async Task<bool> CreateIndex()
{
return await _searchService.CreateIndexAsync();
}
/// <summary>
/// 删除搜索索引。
/// </summary>
[HttpPost]
public async Task DeleteIndex()
{
await _searchService.DeleteIndexAsync();
}
/// <summary>
/// 手动刷新搜索索引,使最新写入立即可搜索。
/// </summary>
[HttpPost]
public async Task RefreshIndex()
{
await _searchService.RefreshIndexAsync();
}
/// <summary>
/// 手动新增或覆盖一条搜索文档。
/// </summary>
[HttpPost]
public async Task AddSearchDocument([FromBody] GoodsSearchDocument document)
{
if (document == null || document.Id <= 0)
{
throw new AppException("请提供有效的搜索文档", ResultCode.ValidationError);
}
await _searchService.AddDocumentAsync(document);
}
/// <summary>
/// 读取指定商品详情:解压缩原始数据并返回 JSON。
/// </summary>
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Store.Modules.CloudProduct.Dtos
namespace Store.Modules.CloudProduct.Dtos
{
/// <summary>
/// OpenSearch 运行状态
@@ -24,18 +20,88 @@ namespace Store.Modules.CloudProduct.Dtos
/// </summary>
public bool Connected { get; set; }
/// <summary>
/// OpenSearch 服务及目标索引是否健康
/// </summary>
public bool Healthy { get; set; }
/// <summary>
/// 集群名称
/// </summary>
public string? ClusterName { get; set; }
/// <summary>
/// 集群状态(green、yellow 或 red)
/// </summary>
public string? ClusterStatus { get; set; }
/// <summary>
/// 集群节点总数
/// </summary>
public int NumberOfNodes { get; set; }
/// <summary>
/// 数据节点数量
/// </summary>
public int NumberOfDataNodes { get; set; }
/// <summary>
/// 活跃主分片数量
/// </summary>
public int ActivePrimaryShards { get; set; }
/// <summary>
/// 活跃分片总数
/// </summary>
public int ActiveShards { get; set; }
/// <summary>
/// 正在迁移的分片数量
/// </summary>
public int RelocatingShards { get; set; }
/// <summary>
/// 正在初始化的分片数量
/// </summary>
public int InitializingShards { get; set; }
/// <summary>
/// 未分配的分片数量
/// </summary>
public int UnassignedShards { get; set; }
/// <summary>
/// 索引是否存在
/// </summary>
public bool IndexExists { get; set; }
/// <summary>
/// 文档总数(索引不存在时为 -1)
/// 索引映射是否符合当前服务要求
/// </summary>
public bool MappingHealthy { get; set; }
/// <summary>
/// 标题字段的索引分析器
/// </summary>
public string? IndexAnalyzer { get; set; }
/// <summary>
/// 标题字段的搜索分析器
/// </summary>
public string? SearchAnalyzer { get; set; }
/// <summary>
/// 文档总数(索引不存在或读取失败时为 -1)
/// </summary>
public long DocumentCount { get; set; } = -1;
/// <summary>
/// 说明信息
/// 状态检测耗时(毫秒)
/// </summary>
public long ElapsedMilliseconds { get; set; }
/// <summary>
/// 状态说明或错误信息
/// </summary>
public string? Message { get; set; }
}
@@ -1,6 +1,8 @@
using System.Diagnostics;
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using OpenSearch.Client;
using OpenSearch.Net;
using Store.Modules.CloudProduct.Dtos;
@@ -19,6 +21,9 @@ namespace Store.Modules.CloudProduct.Services
Task DeleteDocumentsAsync(IEnumerable<long> ids);
Task DeleteIndexAsync();
Task<OpenSearchStatusDto> GetStatusAsync();
Task<bool> IndexExistsAsync();
Task<bool> IsIndexMappingHealthyAsync();
Task RefreshIndexAsync();
Task<long> RebuildIndexAsync(IEnumerable<GoodsEntity> goodsList);
Task<(long Total, List<GoodsSearchDocument> Documents)> SearchByTitleAsync(GoodsSearchRequest request, GoodsSearchSortBy sortBy = GoodsSearchSortBy.Relevance);
}
@@ -33,13 +38,15 @@ namespace Store.Modules.CloudProduct.Services
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(5);
// 批量写入/建索引/删除索引等管理类操作超时(数据量大时需要更长,可挪到配置文件)
private static readonly TimeSpan AdminTimeout = TimeSpan.FromSeconds(30);
private readonly ILogger<OpenSearchService> _logger;
public OpenSearchService(IConfiguration configuration)
public OpenSearchService(IConfiguration configuration,ILogger<OpenSearchService> logger)
{
var config = configuration.GetSection("Search");
_nodeUrl = config.GetValue<string>("Url") ?? "";
_indexName = config.GetValue<string>("IndexName") ?? "";
_client = CreateClient();
_logger = logger;
}
private IOpenSearchClient CreateClient()
@@ -100,34 +107,38 @@ namespace Store.Modules.CloudProduct.Services
}
}
/// <summary>
/// 校验索引映射是否健康:title 字段必须使用 IK 分析器
/// </summary>
private async Task<bool> IsIndexMappingHealthyAsync()
public async Task<bool> IsIndexMappingHealthyAsync()
{
try
{
var getResponse = await _client.Indices.GetAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!getResponse.IsValid || !getResponse.Indices.TryGetValue(_indexName, out var indexState))
{
return false;
}
var mappingStatus = await GetIndexMappingStatusAsync();
return mappingStatus.Healthy;
}
var mapping = indexState.Mappings;
if (mapping?.Properties == null
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|| property is not ITextProperty textProperty)
{
return false;
}
return textProperty.Analyzer == "ik_max_word" && textProperty.SearchAnalyzer == "ik_smart";
}
catch
private async Task<(bool Healthy, string? IndexAnalyzer, string? SearchAnalyzer)> GetIndexMappingStatusAsync()
{
var getResponse = await _client.Indices.GetAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!getResponse.IsValid)
{
return false;
throw new AppException("读取索引映射失败: " + getResponse.DebugInformation);
}
if (!getResponse.Indices.TryGetValue(_indexName, out var indexState))
{
return (false, null, null);
}
var mapping = indexState.Mappings;
if (mapping?.Properties == null
|| !mapping.Properties.TryGetValue(new PropertyName("title"), out var property)
|| property is not ITextProperty textProperty)
{
return (false, null, null);
}
var indexAnalyzer = textProperty.Analyzer;
var searchAnalyzer = textProperty.SearchAnalyzer;
var usesIk = indexAnalyzer == "ik_max_word" && searchAnalyzer == "ik_smart";
var usesStandard = indexAnalyzer == "standard" && searchAnalyzer == "standard";
return (usesIk || usesStandard, indexAnalyzer, searchAnalyzer);
}
private async Task<CreateIndexResponse> TryCreateIndexAsync(bool useIk)
@@ -173,6 +184,23 @@ namespace Store.Modules.CloudProduct.Services
}
}
public async Task<bool> IndexExistsAsync()
{
var response = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
return response.Exists;
}
public async Task RefreshIndexAsync()
{
var response = await _client.Indices.RefreshAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
if (!response.IsValid)
{
throw new AppException("刷新索引失败: " + response.DebugInformation);
}
}
/// <summary>
/// ============ 3.1 批量写入商品搜索文档(存在则覆盖) ============
/// </summary>
@@ -198,8 +226,7 @@ namespace Store.Modules.CloudProduct.Services
}
// 刷新索引,保证数据立即可搜索(仅用于演示,生产环境不建议频繁 Refresh)
await _client.Indices.RefreshAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
await RefreshIndexAsync();
}
/// <summary>
@@ -260,8 +287,7 @@ namespace Store.Modules.CloudProduct.Services
throw new AppException("删除 OpenSearch 文档失败: " + response.DebugInformation);
}
await _client.Indices.RefreshAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(AdminTimeout)));
await RefreshIndexAsync();
}
/// <summary>
@@ -279,8 +305,7 @@ namespace Store.Modules.CloudProduct.Services
if (response.IsValid)
{
await _client.Indices.RefreshAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
await RefreshIndexAsync();
}
else
{
@@ -414,6 +439,7 @@ namespace Store.Modules.CloudProduct.Services
/// </summary>
public async Task<OpenSearchStatusDto> GetStatusAsync()
{
var stopwatch = Stopwatch.StartNew();
var status = new OpenSearchStatusDto
{
NodeUrl = _nodeUrl,
@@ -428,28 +454,65 @@ namespace Store.Modules.CloudProduct.Services
if (!ping.IsValid)
{
status.Message = ping.DebugInformation;
_logger.LogError(ping.DebugInformation);
return status;
}
_logger.LogInformation("OpenSerach 已连接");
var clusterHealth = await _client.Cluster.HealthAsync(Indices.All, h => h
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
if (!clusterHealth.IsValid)
{
status.Message = clusterHealth.DebugInformation;
return status;
}
var exists = await _client.Indices.ExistsAsync(_indexName, d => d
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.IndexExists = exists.Exists;
status.ClusterName = clusterHealth.ClusterName;
status.ClusterStatus = clusterHealth.Status.ToString().ToLowerInvariant();
status.NumberOfNodes = clusterHealth.NumberOfNodes;
status.NumberOfDataNodes = clusterHealth.NumberOfDataNodes;
status.ActivePrimaryShards = clusterHealth.ActivePrimaryShards;
status.ActiveShards = clusterHealth.ActiveShards;
status.RelocatingShards = clusterHealth.RelocatingShards;
status.InitializingShards = clusterHealth.InitializingShards;
status.UnassignedShards = clusterHealth.UnassignedShards;
status.IndexExists = await IndexExistsAsync();
if (exists.Exists)
if (status.IndexExists)
{
var mappingStatus = await GetIndexMappingStatusAsync();
status.MappingHealthy = mappingStatus.Healthy;
status.IndexAnalyzer = mappingStatus.IndexAnalyzer;
status.SearchAnalyzer = mappingStatus.SearchAnalyzer;
var count = await _client.CountAsync<GoodsSearchDocument>(c => c
.Index(_indexName)
.RequestConfiguration(r => r.RequestTimeout(QueryTimeout)));
status.DocumentCount = count.IsValid ? count.Count : -1;
if (!count.IsValid)
{
status.Message = count.DebugInformation;
}
}
else
{
status.Message = $"索引 [{_indexName}] 不存在";
}
status.Healthy = status.IndexExists
&& status.MappingHealthy
&& status.ClusterStatus != "red"
&& status.DocumentCount >= 0;
}
catch (Exception ex)
{
status.Connected = false;
status.Healthy = false;
status.Message = ex.Message;
}
finally
{
stopwatch.Stop();
status.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds;
}
return status;
}
+14 -5
View File
@@ -1,13 +1,14 @@
using MAX.Core.DependencyInjection;
using MAX.Core.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Store.Modules.CloudProduct.Services;
using System.ComponentModel;
namespace Store.Modules.CloudProduct
{
/// <summary>
/// CloudProduct 模块的启动器(<see cref="IStartup"/>):由 AddStoreHub() 自动发现,
/// 按 <see cref="Priority"/> 降序执行 <see cref="Initialize"/> + <see cref="Register"/>。
/// 需要配置或管道装配(app)的部分不在这里,见 <see cref="CloudProductModule"/>。
/// </summary>
public class Startup : IStartup
{
public int Priority => IntPriority.Normal;
@@ -19,5 +20,13 @@ namespace Store.Modules.CloudProduct
public void Register(IServiceRegistrar registrar)
{
}
public async void Start(IServiceProvider serviceProvider)
{
var openSearchService = serviceProvider.GetRequiredService<OpenSearchService>();
await openSearchService.GetStatusAsync();
}
}
}