当一个业务模块的数据需要出现在顶栏命令面板的全局搜索中,并能在 /host/search 治理页执行**全量对账(初始化/重建)**时,需要完成三件事:增量索引事件接线、权威重建来源、宿主来源目录登记。本教程以 Tracker 模块(工单 tracker.item)的真实实现为范本,带你走完从零到端到端验证的完整闭环。
全链路数据流
增量链路的时延承诺:命令提交 → Outbox 投递 → CAP 消费 → 引擎延迟批量提交(阈值 1000 条或 2 秒循环)→ 提交完成即刷新 SearcherManager。真实环境实测新文档约 4 秒内可被搜索命中。
第一步:定义索引键与稳定业务类型
索引键(IndexKey)是来源、索引分区与治理页之间的稳定自然键。在 BitzOrcas.Platform.Search.Contracts 的 GlobalSearchIndexKeys 中登记常量:
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.cspublic 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 分页:
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 | 从存储字段读取展示标题/摘要 | 必须在投影字段字典里 |
RequiresAuthoritativeAccessEvaluation | true 时必须有匹配 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 在处理器侧先按索引所有者字段粗滤,复核器做终审。
第六步:测试与治理锁定
- 重建源单测:fake 读模型,断言键、字段同构、游标推进、空批结束、读模型失败抛
InvalidOperationException(失败关闭)。参照tests/BitzOrcas.Unit.Tests/Tracker/SearchIndexRebuildSourceTests.cs。 - 配置对齐架构测试:在
SearchArchitectureTests维护”配置来源 ⇄ 重建源”双向相等断言,防止未来出现只读来源或配置漂移。 - 组合契约测试:若端口有 fail-closed 兜底,断言生产注册覆盖兜底(参照
SearchPlatformCompositionTests)。
第七步:端到端验证
治理页(/host/search):四个来源应显示 ready 且可重建;全新环境对 ExpectedVersion=0 发起初始化(需审批工单 + 确认令牌 REBUILD INDEX + 职责分离)。
命令行验证增量闭环(真实环境实测口径):
# 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
相关阅读
- 模块参考:Search 模块、索引事件与重建、HTTP 与授权面
- 前置指南:编写写操作切片、从零新增独立业务模块
- 仓库内范本:
src/Platform/Tracker/**(增量投影与重建源)、src/Platform/Workflow/**(引擎监听器式接线)