Skip to content
bitzorcas
中EN

Tutorial

实战:业务模块接入全局搜索与索引治理

掌握业务模块接入 BitzOrcas.Modern 全局搜索的完整闭环:同事务发布增量索引事件、编写权威重建源、宿主来源目录配置、权威命中复核器与架构测试锁定,以及治理页全量对账与端到端验证。

Last updated

当一个业务模块的数据需要出现在顶栏命令面板的全局搜索中,并能在 /host/search 治理页执行**全量对账(初始化/重建)**时,需要完成三件事:增量索引事件接线、权威重建来源、宿主来源目录登记。本教程以 Tracker 模块(工单 tracker.item)的真实实现为范本,带你走完从零到端到端验证的完整闭环。

全链路数据流

消费方Search 平台模块同事务基础设施业务模块(如 Tracker)同事务发布ExpectedVersion=0 初始化

写命令 Handler
(创建/编辑/指派/删除)

增量投影助手
XxxSearchIndexProjection

权威重建源
ISearchIndexRebuildSource

读模型专用批次读
ListForSearchIndexAsync

CAP Outbox
(与业务写同事务)

RabbitMQ

SearchIndexEventHandler
(CAP 消费者)

Lucene 引擎
Upsert / Delete / Rebuild

提交后即时刷新
SearcherManager

全局搜索命令面板
/api/search/global

治理页 /host/search
审批门控全量对账

增量链路的时延承诺:命令提交 → Outbox 投递 → CAP 消费 → 引擎延迟批量提交(阈值 1000 条或 2 秒循环)→ 提交完成即刷新 SearcherManager。真实环境实测新文档约 4 秒内可被搜索命中。


第一步:定义索引键与稳定业务类型

索引键(IndexKey)是来源、索引分区与治理页之间的稳定自然键。在 BitzOrcas.Platform.Search.Contracts 的 GlobalSearchIndexKeys 中登记常量:

src/Platform/Search/BitzOrcas.Platform.Search.Contracts/Search/GlobalSearchContracts.cs
public static class GlobalSearchIndexKeys
{
public const string Tickets = "tickets";
public const string TrackerItems = "tracker.item";
public const string WorkflowTasks = "workflow-tasks";
public const string SupportReports = "support-reports";
public const string SupportIssues = "support-issues";
// 新模块在此追加自己的稳定键
}

同时确定模块的稳定业务类型(BusinessType),它同时出现在增量事件、重建文档与宿主配置三处,必须逐字一致——这是检索命中后按来源过滤的依据:

// 模块内投影助手中的常量(Tracker 范例)
internal const string EntityType = "TrackerItem";

第二步:增量投影(与业务写同事务发布索引事件)

2.1 编写投影助手

投影助手是一个静态类,把聚合快照映射为 SearchIndexChangedIntegrationEvent 的字段字典。参照 src/Platform/Tracker/BitzOrcas.Platform.Tracker.Application/Support/TrackerItemSearchIndexProjection.cs:

// <模块>.Application/Support/XxxSearchIndexProjection.cs
public static class XxxSearchIndexProjection
{
internal const string EntityType = "XxxItem"; // 与宿主配置 BusinessType 逐字一致
/// <summary>发布创建/更新快照(Created/Updated)</summary>
public static Task PublishUpsertAsync(
IIntegrationEventPublisher<SearchIndexChangedIntegrationEvent> events,
XxxItem item,
SearchIndexAction action,
DateTimeOffset occurredAt,
CancellationToken cancellationToken)
{
var owners = new[] { item.ReporterId, item.AssigneeId }
.Where(owner => !string.IsNullOrWhiteSpace(owner))
.Distinct(StringComparer.Ordinal)
.ToArray();
var fields = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["Subject"] = item.Subject, // 标题字段:命中后展示
["Description"] = item.Description, // 摘要/检索字段
// Own DataScope 所有者:报告人/经办人可见性的索引侧依据
[GlobalSearchStoredFields.OwnerUserId] = item.ReporterId,
[GlobalSearchStoredFields.OwnerUserIds] = string.Join(
GlobalSearchStoredFields.MultiValueSeparator, owners)
};
return events.PublishAsync(
new SearchIndexChangedIntegrationEvent(
GlobalSearchIndexKeys.XxxItems, // 来源键
item.Id, // 业务主键(非存储 Id)
item.TenantId, // 租户事实只来自聚合
EntityType,
action,
occurredAt.UtcTicks,
occurredAt,
JsonSerializer.Serialize(fields)),
cancellationToken);
}
}

2.2 在写命令中接线(关键:同事务)

在每个改变可检索字段的写命令 Handler 中,保存成功后立即发布。注入 IIntegrationEventPublisher<SearchIndexChangedIntegrationEvent> 并调用投影助手:

// 创建命令(Tracker 范例:CreateTrackerItem.cs)
var saved = await items.SaveAsync(item, cancellationToken).ConfigureAwait(false);
if (saved.IsFailure)
{
return Result.Failure<TrackerItemSummaryDto>(saved.Error);
}
collector.Track(item);
// 与业务写同事务发布 Search 增量索引事件,保证新文档创建后立即可被全局搜索命中。
await TrackerItemSearchIndexProjection.PublishUpsertAsync(
searchIndexEvents, item, SearchIndexAction.Created, clock.UtcNow, cancellationToken);

生命周期命令推荐”单一接缝”模式:Tracker 把编辑/指派/状态迁移等全部生命周期命令收敛到一个 PersistAsync 私有方法,在那里统一发布,一处覆盖全部写路径:

// TrackerItemLifecycle.cs 的 PersistAsync 接缝(简化)
private async Task<Result> PersistAsync(TrackerItem item, Result mutation, CancellationToken ct)
{
if (mutation.IsFailure) return mutation;
var saved = await items.SaveAsync(item, ct).ConfigureAwait(false);
if (saved.IsFailure) return saved;
collector.Track(item);
if (item.IsDeleted)
{
// 软删除发布 Deleted 动作:引擎按存储 Id 移除文档
await searchIndexEvents.PublishAsync(
new SearchIndexChangedIntegrationEvent(
GlobalSearchIndexKeys.TrackerItems, item.Id, item.TenantId,
TrackerItemSearchIndexProjection.EntityType,
SearchIndexAction.Deleted, clock.UtcNow.UtcTicks, clock.UtcNow, "{}"),
ct);
}
else
{
// 编辑(主题/描述)、指派(所有者字段)等都经此刷新快照
await TrackerItemSearchIndexProjection.PublishUpsertAsync(
searchIndexEvents, item, SearchIndexAction.Updated, clock.UtcNow, ct);
}
return Result.Success();
}

第三步:权威重建源(治理页全量对账)

治理页的”重建/初始化”按 IndexKey 匹配 ISearchIndexRebuildSource;没有重建源的来源会显示”未注册权威源”且只能只读(历史上 support-reports / support-issues 曾因此长期不可对账)。架构测试已双向锁定:宿主配置的每个来源必须有重建源。

3.1 读模型端口增加专用批次读

不要复用列表查询的 DTO(通常缺字段、带无关投影),在模块 Contracts 的读模型端口上新增只投影索引字段的方法:

// Contracts:端口方法(Tracker 范例:ITrackerItemReadModelStore)
Task<Result<IReadOnlyList<TrackerItemSearchIndexRecord>>> ListForSearchIndexAsync(
BatchReadRequest batch, CancellationToken cancellationToken);
// Contracts:批次行记录(只含索引字段)
public sealed record TrackerItemSearchIndexRecord(
string ItemId, string Subject, string Description,
string ReporterId, string? AssigneeId);

Infrastructure 实现按稳定主键排序做 offset 分页:

Infrastructure/ReadModels/XxxReadModelStore.cs
public async Task<Result<IReadOnlyList<TrackerItemSearchIndexRecord>>> ListForSearchIndexAsync(
BatchReadRequest batch, CancellationToken cancellationToken)
{
var rows = await items.ListAsync(row => !row.IsDeleted, cancellationToken).ConfigureAwait(false);
var page = rows
.OrderBy(row => row.Id, StringComparer.Ordinal)
.Skip(batch.Offset)
.Take(batch.Take)
.Select(row => new TrackerItemSearchIndexRecord(
row.Id, row.Subject, row.Description, row.ReporterId, row.AssigneeId))
.ToArray();
return Result<IReadOnlyList<TrackerItemSearchIndexRecord>>.Success(page);
}

别忘了给 fail-closed 兜底适配器(Unavailable*Stores)补上返回 StoreUnavailable 的同名桩。

3.2 实现重建源

// <模块>.Application/Support/XxxSearchIndexRebuildSource.cs
[RegisterScopedEnumerable<ISearchIndexRebuildSource>]]
public sealed class XxxSearchIndexRebuildSource(IXxxReadModelStore readModels)
: ISearchIndexRebuildSource
{
private const string EntityType = "XxxItem";
public string IndexKey => GlobalSearchIndexKeys.XxxItems;
public async Task<IReadOnlyList<SearchIndexRebuildDocument>> GetBatchAsync(
string tenantId, string? lastCursor, int pageSize, CancellationToken ct)
{
var offset = ParseCursor(lastCursor);
var recordsResult = await readModels.ListForSearchIndexAsync(
new BatchReadRequest(offset, pageSize), ct).ConfigureAwait(false);
if (recordsResult.IsFailure)
{
// 读模型不可用必须失败关闭:静默返回空批次会让治理页误判"0 文档已完成"
throw new InvalidOperationException(
$"Xxx search rebuild source is unavailable: {recordsResult.Error.Code}.");
}
return recordsResult.GetValueOrThrow()
.Select((record, index) => new SearchIndexRebuildDocument(
record.ItemId,
EntityType,
BuildFields(record), // 字段与增量投影完全同构!
(offset + index + 1).ToString(CultureInfo.InvariantCulture)))
.ToArray();
}
private static int ParseCursor(string? cursor)
=> int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var v) && v >= 0
? v
: throw new InvalidOperationException("Xxx search rebuild cursor is invalid.");
}

第四步:宿主来源目录配置

在 src/Hosts/BitzOrcas.Api/appsettings.json 的 Search:GlobalSources 登记来源(服务端目录,客户端不可覆盖):

{
"IndexKey": "tracker.item",
"DisplayName": "工单",
"Module": "tickets",
"ResourceType": "ticket",
"BusinessType": "TrackerItem",
"NavigationTemplate": "/tickets/{id}",
"TitleField": "Subject",
"SubtitleField": "Description",
"RequiresAuthoritativeAccessEvaluation": true,
"SearchFields": ["Subject", "Description"]
}
字段说明易错点
IndexKey与 GlobalSearchIndexKeys 常量和重建源 IndexKey 三处一致拼错→治理页匹配不到重建源(“未注册权威源”)
Module / ResourceType来源级授权的资源描述符(决定谁能搜到该来源)用权限目录里的实际 module/resource,不是显示名
BusinessType命中过滤依据,必须与事件/重建文档的 EntityType 逐字一致不一致→永远搜不到(命中被来源过滤丢弃)
NavigationTemplate命中导航路径,{id} 会被 URL 转义替换指向真实前端路由
TitleField / SubtitleField从存储字段读取展示标题/摘要必须在投影字段字典里
RequiresAuthoritativeAccessEvaluationtrue 时必须有匹配 SourceKey 的复核器,否则该来源失败关闭复核器缺失→来源整体不可用(fail-closed)
SearchFields参与全文检索的字段名列表与投影字段名一致

配置漂移由架构测试锁定(SearchArchitectureTests):配置键集合 == 重建源键集合、BusinessType == 投影 EntityType。


第五步(按需):权威命中复核器

索引快照可能陈旧(所有者调岗、单据转办)。当来源需要按权威业务状态实时裁决命中可见性时,实现 IGlobalSearchHitAccessEvaluator:

[RegisterScopedEnumerable<IGlobalSearchHitAccessEvaluator>]]
public sealed class XxxGlobalSearchAccessEvaluator(
IXxxRepository items,
XxxAuthorizationService authorization,
ICurrentUser currentUser): IGlobalSearchHitAccessEvaluator
{
// 必须与宿主配置 IndexKey 一致,目录据此路由复核
public string SourceKey => GlobalSearchIndexKeys.XxxItems;
public async ValueTask<bool> IsAllowedAsync(
GlobalSearchHitAccessContext context, CancellationToken ct)
{
var user = currentUser.User;
if (!string.Equals(context.SourceKey, SourceKey, StringComparison.Ordinal)
|| !string.Equals(context.TenantId, user.TenantId, StringComparison.Ordinal))
{
return false;
}
// 回源权威数据 + 统一授权决策,绝不信任索引字段
var item = await items.FindAsync(context.BusinessId, ct).ConfigureAwait(false);
return item.IsSuccess
&& (await authorization.EnsureCanViewAsync(user, item.GetValueOrThrow(), ct)).IsSuccess;
}
}

规则:RequiresAuthoritativeAccessEvaluation=true 的来源必须有复核器(缺失即失败关闭);Own DataScope 在处理器侧先按索引所有者字段粗滤,复核器做终审。


第六步:测试与治理锁定

  1. 重建源单测:fake 读模型,断言键、字段同构、游标推进、空批结束、读模型失败抛 InvalidOperationException(失败关闭)。参照 tests/BitzOrcas.Unit.Tests/Tracker/SearchIndexRebuildSourceTests.cs。
  2. 配置对齐架构测试:在 SearchArchitectureTests 维护”配置来源 ⇄ 重建源”双向相等断言,防止未来出现只读来源或配置漂移。
  3. 组合契约测试:若端口有 fail-closed 兜底,断言生产注册覆盖兜底(参照 SearchPlatformCompositionTests)。

第七步:端到端验证

治理页(/host/search):四个来源应显示 ready 且可重建;全新环境对 ExpectedVersion=0 发起初始化(需审批工单 + 确认令牌 REBUILD INDEX + 职责分离)。

命令行验证增量闭环(真实环境实测口径):

Terminal window
# 1. 登录取 token (通过 /api/auth/cipher-key 获取公钥加密登录凭据)
# 2. 创建一条业务数据
curl -X POST http://localhost:6881/api/tracker/items \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"projectId":"PRJ-2026-001","type":"Task","subject":"zephyr 接线验证","description":"验证全局搜索增量索引事件闭环"}'
# 3. 等待约 4 秒(Outbox→CAP→引擎提交→刷新),搜索应命中
curl -X POST http://localhost:6881/api/search/global \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"Keyword":"zephyr","Take":10}'

排障速查

症状根因处置
治理页来源 unavailable + SearchIndex.Statistics.Unavailable组合根引擎是 fail-closed 桩(TryAdd 未覆盖)确认宿主 Replace 真实引擎(参照 SearchDependencyInjection)
来源只读 / “未注册权威源”无匹配 ISearchIndexRebuildSource补重建源;架构测试会阻止此类配置合入
创建后搜不到命令没接线 / BusinessType 与配置不一致 / 提交未刷新逐一核对第二、四步
全新来源搜索报不可用索引从未初始化(无目录无提交)治理页初始化;或等首批事件写入(未初始化会被降级为零命中来源而非整体失败)
重建 403 SearchIndex.Rebuild.ApprovalRequired审批工单未批准/缺失OpsExtension 事项流转到 Resolved 后携工单号重建;创建者≠操作人(SoD)
重建 409 版本冲突ExpectedVersion 与快照不符重读总览取当前版本;全新初始化用 0

接入检查清单

  • GlobalSearchIndexKeys 登记稳定键(仅 [A-Za-z0-9_.-],≤128)
  • 增量投影助手 + 所有改变索引字段的写命令同事务接线(含软删除 Deleted)
  • 读模型 ListForSearchIndexAsync 批次读 + fail-closed 兜底桩
  • ISearchIndexRebuildSource(字段与投影同构、游标 offset 语义、失败关闭)
  • 宿主 Search:GlobalSources 登记(九个字段逐项核对,BusinessType 三处一致)
  • 按需实现 IGlobalSearchHitAccessEvaluator(RequiresAuthoritativeAccessEvaluation=true 时必做)
  • 重建源单测 + 配置对齐架构测试
  • 治理页四态验证 + 创建→4 秒→命中 E2E

相关阅读

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%