|
|
// ============================================================================
|
|
|
// 【文件说明】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<T> 模式】
|
|
|
/// IOptions<T> 是 ASP.NET Core 的配置模式:
|
|
|
/// 1. 在 Startup 中注册配置
|
|
|
/// 2. 通过依赖注入获取 IOptions<T>
|
|
|
/// 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 和 InnoDB(MySQL 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);
|
|
|
}
|
|
|
}
|