You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

416 lines
17 KiB
C#

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ============================================================================
// 【文件说明】HwSearchSchemaService.cs - 搜索表结构初始化服务
// ============================================================================
// 这个服务负责自动创建搜索索引表和索引。
//
// 【为什么需要自动建表?】
// 1. 开发环境:快速启动,不需要手动执行 SQL 脚本
// 2. 测试环境:每次测试前可以重建干净的表
// 3. CI/CD自动化部署时自动创建表结构
//
// 【设计模式 - 单例初始化】
// 这个服务使用"延迟初始化 + 双重检查锁"模式:
// 1. 第一次调用时才创建表
// 2. 使用锁保证线程安全
// 3. 使用 volatile 标记避免指令重排
//
// 【与 Java Spring Boot 的对比】
// Java 通常用 Flyway 或 Liquibase 做数据库迁移:
// @Bean
// public Flyway flyway() {
// return Flyway.configure()
// .locations("db/migration")
// .dataSource(dataSource)
// .load();
// }
//
// C# 这里用代码直接建表,更简单但不适合复杂迁移场景。
// ============================================================================
using System.Data;
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Admin.NET.Plugin.HwPortal;
/// <summary>
/// 搜索表结构初始化服务。
/// <para>
/// 【服务职责】
/// 1. 检查搜索表是否存在
/// 2. 创建搜索表(如果不存在)
/// 3. 创建索引(唯一索引、普通索引、全文索引)
/// </para>
/// <para>
/// 【C# 语法知识点 - sealed 密封类 + ITransient】
/// sealed + ITransient 的组合表示:
/// - 这是一个瞬态服务(每次请求创建新实例)
/// - 不能被继承(不需要扩展)
///
/// 对比 Java
/// Java 通常用 @Service + @Scope("prototype") 实现类似效果。
/// </para>
/// </summary>
public sealed class HwSearchSchemaService : IHwSearchSchemaService, ITransient
{
/// <summary>
/// 表名常量。
/// <para>
/// 【C# 语法知识点 - const 常量】
/// const 是编译期常量,值在编译时就确定了。
/// 编译器会把常量值直接嵌入调用处。
/// </para>
/// </summary>
private const string TableName = "hw_portal_search_doc";
/// <summary>
/// 初始化锁。
/// <para>
/// 【C# 语法知识点 - SemaphoreSlim 信号量】
/// SemaphoreSlim 是轻量级信号量,用于异步场景的并发控制。
///
/// SemaphoreSlim(1, 1) 表示:
/// - 初始计数1允许 1 个线程进入)
/// - 最大计数1最多允许 1 个线程同时进入)
///
/// 这是一个"互斥锁"Mutex保证同一时间只有一个线程能执行初始化。
///
/// 对比 Java
/// Java 用 ReentrantLock 或 synchronized
/// private static final ReentrantLock lock = new ReentrantLock();
/// lock.lock();
/// try { ... } finally { lock.unlock(); }
///
/// C# 的 SemaphoreSlim 支持 async/await更适合异步编程。
/// </para>
/// <para>
/// 【为什么用 static readonly
/// - static所有实例共享同一个锁
/// - readonly只能在声明时或构造函数中赋值
///
/// 这样保证整个应用只有一个锁,所有线程都竞争同一个锁。
/// </para>
/// </summary>
private static readonly SemaphoreSlim InitLock = new(1, 1);
/// <summary>
/// 初始化完成标记。
/// <para>
/// 【C# 语法知识点 - volatile 关键字】
/// volatile 表示"易变字段",告诉编译器:
/// 1. 不要优化这个字段的访问(如缓存到寄存器)
/// 2. 每次读写都直接操作内存
/// 3. 避免指令重排
///
/// 为什么需要 volatile
/// 在多线程环境下,一个线程修改了 _initialized其他线程要能立即看到。
///
/// 对比 Java
/// Java 也有 volatile 关键字,含义完全一样:
/// private static volatile boolean initialized = false;
/// </para>
/// </summary>
private static volatile bool _initialized;
/// <summary>
/// 数据库上下文。
/// </summary>
private readonly HwPortalSearchDbContext _db;
/// <summary>
/// 搜索配置选项。
/// <para>
/// 【C# 语法知识点 - IOptions&lt;T&gt; 模式】
/// IOptions&lt;T&gt; 是 ASP.NET Core 的配置模式:
/// 1. 在 Startup 中注册配置
/// 2. 通过依赖注入获取 IOptions&lt;T&gt;
/// 3. .Value 获取配置实例
///
/// 对比 Java Spring Boot
/// Java 通常直接注入配置类:
/// @Autowired
/// private HwPortalSearchProperties properties;
///
/// C# 的 IOptions 模式更灵活,支持配置热更新。
/// </para>
/// </summary>
private readonly HwPortalSearchOptions _options;
/// <summary>
/// 构造函数(依赖注入)。
/// </summary>
/// <param name="db">数据库上下文</param>
/// <param name="options">配置选项</param>
public HwSearchSchemaService(HwPortalSearchDbContext db, IOptions<HwPortalSearchOptions> options)
{
_db = db;
_options = options.Value;
}
/// <summary>
/// 确保搜索表已创建。
/// <para>
/// 【双重检查锁模式】
/// 这是经典的线程安全单例初始化模式:
///
/// 1. 第一次检查无锁if (!_options.AutoInitSchema || _initialized)
/// - 快速路径:如果已初始化,直接返回,不获取锁
/// - 性能优化:避免每次都获取锁
///
/// 2. 获取锁await InitLock.WaitAsync(cancellationToken)
/// - 保证同一时间只有一个线程能执行初始化
///
/// 3. 第二次检查有锁if (_initialized)
/// - 防止重复初始化
/// - 场景:线程 A 和 B 同时通过第一次检查A 先获取锁并初始化完成,
/// B 获取锁后要再次检查是否已初始化
///
/// 对比 Java
/// Java 的双重检查锁写法类似:
/// if (!initialized) {
/// synchronized (lock) {
/// if (!initialized) {
/// // 初始化
/// initialized = true;
/// }
/// }
/// }
///
/// C# 的异步版本需要用 SemaphoreSlim 替代 lock。
/// </para>
/// </summary>
/// <param name="cancellationToken">取消令牌</param>
public async Task EnsureCreatedAsync(CancellationToken cancellationToken = default)
{
// 【第一次检查 - 快速路径】
// 如果配置关闭了自动初始化,或者已经初始化完成,直接返回。
if (!_options.AutoInitSchema || _initialized)
{
return;
}
// 【获取锁】
// WaitAsync 会阻塞当前线程,直到获取锁。
// cancellationToken 支持取消等待。
await InitLock.WaitAsync(cancellationToken);
try
{
// 【第二次检查 - 防止重复初始化】
if (_initialized)
{
return;
}
// 【执行建表 SQL】
// ExecuteSqlRawAsync 执行原生 SQL 语句。
// """...""" 是 C# 11 的"原始字符串字面量"语法:
// - 三个双引号开始和结束
// - 内部可以包含换行、引号等特殊字符
// - 不需要转义
//
// 对比 Java
// Java 需要用字符串拼接或文本块Java 15+
// String sql = """
// CREATE TABLE IF NOT EXISTS ...
// """;
//
// C# 的原始字符串更灵活,可以控制缩进。
await _db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS `hw_portal_search_doc` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '',
`doc_id` VARCHAR(128) NOT NULL COMMENT '',
`source_type` VARCHAR(32) NOT NULL COMMENT '',
`biz_id` VARCHAR(64) NULL COMMENT '',
`title` VARCHAR(500) NULL COMMENT '',
`content` LONGTEXT NULL COMMENT '',
`web_code` VARCHAR(64) NULL COMMENT '',
`type_id` VARCHAR(64) NULL COMMENT 'ID',
`device_id` VARCHAR(64) NULL COMMENT 'ID',
`menu_id` VARCHAR(64) NULL COMMENT 'ID',
`document_id` VARCHAR(64) NULL COMMENT 'ID',
`base_score` INT NOT NULL DEFAULT 0 COMMENT '',
`route` VARCHAR(255) NULL COMMENT '',
`route_query_json` JSON NULL COMMENT '',
`edit_route` VARCHAR(255) NULL COMMENT '',
`is_delete` CHAR(1) NOT NULL DEFAULT '0' COMMENT '',
`updated_at` DATETIME NULL COMMENT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '',
`modified_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='hw-portal ';
""",
cancellationToken);
// 【创建索引】
// 索引可以加速查询,但需要单独创建。
// EnsureIndexAsync 方法会检查索引是否存在,不存在才创建。
await EnsureIndexAsync("uk_hw_portal_search_doc_doc_id",
"ALTER TABLE `hw_portal_search_doc` ADD UNIQUE KEY `uk_hw_portal_search_doc_doc_id` (`doc_id`);",
cancellationToken);
await EnsureIndexAsync("idx_hw_portal_search_doc_source_type",
"ALTER TABLE `hw_portal_search_doc` ADD KEY `idx_hw_portal_search_doc_source_type` (`source_type`);",
cancellationToken);
await EnsureIndexAsync("idx_hw_portal_search_doc_updated_at",
"ALTER TABLE `hw_portal_search_doc` ADD KEY `idx_hw_portal_search_doc_updated_at` (`updated_at`);",
cancellationToken);
// 【全文索引】
// FULLTEXT 索引用于全文搜索,支持 LIKE '%keyword%' 查询优化。
// 只有 MyISAM 和 InnoDBMySQL 5.6+)引擎支持全文索引。
await EnsureIndexAsync("ft_hw_portal_search_doc_title_content",
"ALTER TABLE `hw_portal_search_doc` ADD FULLTEXT KEY `ft_hw_portal_search_doc_title_content` (`title`, `content`);",
cancellationToken);
// 【标记初始化完成】
// 必须在所有操作完成后才设置,否则其他线程可能提前返回。
_initialized = true;
}
finally
{
// 【释放锁】
// finally 保证无论成功还是异常,锁都会被释放。
// 如果不释放,后续所有请求都会被阻塞。
InitLock.Release();
}
}
/// <summary>
/// 确保索引存在(不存在则创建)。
/// </summary>
/// <param name="indexName">索引名称</param>
/// <param name="ddl">创建索引的 DDL 语句</param>
/// <param name="cancellationToken">取消令牌</param>
private async Task EnsureIndexAsync(string indexName, string ddl, CancellationToken cancellationToken)
{
// 先检查索引是否存在。
long count = await GetIndexCountAsync(indexName, cancellationToken);
if (count > 0)
{
// 索引已存在,跳过创建。
return;
}
// 索引不存在,执行创建语句。
await _db.Database.ExecuteSqlRawAsync(ddl, cancellationToken);
}
/// <summary>
/// 查询索引是否存在。
/// <para>
/// 【查询方式】
/// 通过查询 information_schema.statistics 表判断索引是否存在:
/// - information_schema 是 MySQL 的元数据库
/// - statistics 表存储了所有表和索引的信息
///
/// 对比 Java JDBC
/// Java 通常用 DatabaseMetaData 获取索引信息:
/// DatabaseMetaData metaData = connection.getMetaData();
/// ResultSet indexes = metaData.getIndexInfo(null, null, "hw_portal_search_doc", false, false);
/// </para>
/// </summary>
/// <param name="indexName">索引名称</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>索引数量0 表示不存在)</returns>
private async Task<long> GetIndexCountAsync(string indexName, CancellationToken cancellationToken)
{
// 【获取底层 ADO.NET 连接】
// _db.Database.GetDbConnection() 获取 EF Core 包装的底层 DbConnection。
// 有时需要直接使用 ADO.NET API如执行原生 SQL、获取元数据等。
DbConnection connection = _db.Database.GetDbConnection();
// 【连接状态检查】
// 如果连接未打开,需要手动打开。
// EF Core 通常会自动管理连接,但这里我们需要确保连接可用。
bool shouldClose = connection.State != ConnectionState.Open;
if (shouldClose)
{
await connection.OpenAsync(cancellationToken);
}
try
{
// 【创建命令】
// await using 是 C# 8 的"异步 using"语法。
// 会自动调用 DisposeAsync适合实现了 IAsyncDisposable 的对象。
//
// 对比 Java
// Java 用 try-with-resources
// try (PreparedStatement stmt = connection.prepareStatement(sql)) {
// ...
// }
await using DbCommand command = connection.CreateCommand();
command.CommandText =
"""
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = @tableName
AND index_name = @indexName;
""";
// 【添加参数】
// 使用参数化查询,防止 SQL 注入。
AddParameter(command, "@tableName", TableName);
AddParameter(command, "@indexName", indexName);
// 【执行查询】
// ExecuteScalarAsync 返回结果集的第一行第一列。
// 这里返回的是 COUNT(1) 的结果,即索引数量。
object result = await command.ExecuteScalarAsync(cancellationToken);
// 【结果转换】
// result 可能是 null 或 DBNull理论上不应该但防御性编程
// Convert.ToInt64 可以处理多种数值类型int, long, decimal 等)。
return result == null || result == DBNull.Value ? 0 : Convert.ToInt64(result);
}
finally
{
// 【关闭连接】
// 如果是我们打开的连接,用完后要关闭。
// 如果连接本来就是打开的,不要关闭(可能是事务中的连接)。
if (shouldClose)
{
await connection.CloseAsync();
}
}
}
/// <summary>
/// 添加命令参数。
/// <para>
/// 【C# 语法知识点 - static 静态方法】
/// private static void AddParameter(...)
///
/// 静态方法不依赖实例状态,可以直接调用。
/// 这个方法是工具方法,不需要访问实例成员,所以声明为 static。
///
/// 对比 Java
/// Java 的静态方法语法完全一样。
/// </para>
/// </summary>
/// <param name="command">数据库命令</param>
/// <param name="name">参数名</param>
/// <param name="value">参数值</param>
private static void AddParameter(DbCommand command, string name, object value)
{
// 【创建参数】
// CreateParameter() 创建一个参数对象。
// 不同数据库提供者的参数类型不同,但都继承自 DbParameter。
DbParameter parameter = command.CreateParameter();
parameter.ParameterName = name;
// 【空值处理】
// value ?? DBNull.Value 表示:
// - 如果 value 不为 null使用 value
// - 如果 value 为 null使用 DBNull.Value
//
// 数据库不能直接存储 C# 的 null需要用 DBNull.Value 表示数据库的 NULL。
parameter.Value = value ?? DBNull.Value;
// 【添加参数到命令】
command.Parameters.Add(parameter);
}
}