统一搜索和利冲共用 ISearchEngine.SearchAsync,但业务语义完全不同:统一搜索返回候选导航;利冲是接案合规决策的输入。两者都不能把 Lucene 命中直接等同为源资源授权或最终业务结论。
1. UnifiedSearch 实际构造的输入
Handler 只设置:
IndexKey = request.IndexKey;TenantId = currentUser.User.TenantId;Query.Skip/Take;- Keyword 非空时,将原字符串分别放入 Name、Subject、Content。
它没有设置 resource types、culture、sort、highlight、field projection、DataScope filter、owner filter 或 source authorization callback。
var input = new SearchPerformInput{ IndexKey = request.IndexKey, // TenantId 只来自认证上下文,这是正确的跨租户基线。 TenantId = currentUser.User.TenantId, Query = { Skip = request.Skip, Take = request.Take }};
if (!string.IsNullOrWhiteSpace(request.Keyword)){ // 同一词同时检索三个硬编码字段;没有场景特定 schema。 foreach (var field in new[] { "Name", "Subject", "Content" }) input.Query.SearchFieldWordsDict[field] = [request.Keyword];}文档不能声称“语言和资源类型成为过滤条件”;源码并没有这些字段。
2. SearchHitItem 投影
外部连接器命中被映射为:
{ "items": [ { "businessId": "case-0042", "businessType": "Case", "matchKeywords": "海岚科技", "hitLevel": 8, "storedFields": { "Name": "海岚科技有限公司", "Subject": "股权并购项目", "InternalNote": "producer 如果写入,当前 API 会原样返回" } } ], "total": 1}SearchHitItemProjection 把所有 StoredFields 的 value ToString(),null 变空字符串,没有字段 allowlist、值长度、类型 schema、敏感度或 masker。BusinessId/BusinessType/MatchKeywords 缺失时也变空字符串,隐藏了索引数据质量问题。
推荐每个 IndexKey 有版本化输出 schema:允许索引字段、允许返回字段、敏感分类、最大长度与 masker。搜索摘要应最小化,详情由 owner endpoint 返回。
3. 候选授权缺失
旧文档描述了 authorizer.FilterAsync(actor,candidates),实际 Handler 没有 authorizer。当前授权只判断调用者是否可执行 search.search.view,不判断:
- 是否可查看命中的具体 Case/Document/Ticket;
- 是否在对应 Office/Department/Team;
- 是否是 owner、participant 或被显式分享;
- 记录是否 sealed/confidential/legal hold;
- StoredFields 中哪些列可见。
正确流程有两种:
索引过滤快,但 ACL 变更必须同步且不能漏撤权;候选后复核更权威,但需要 oversampling、批量授权和“过滤后补页”,否则页面可能不足 Take。高敏场景可组合两者:索引粗过滤 + owner 最终复核。
4. Total 的泄漏语义
当前 Total 来自引擎原始命中数。若未来只过滤 Items 而保留 Total,未授权对象数量仍会侧信道泄漏。授权后分页必须同时定义 Total:
- 精确 authorized total,成本高;
- “至少 N 条”或不返回 Total;
- 基于授权过滤的 cursor page;
- 仅返回
hasMore。
不能返回 tenant-wide 原始 Total 再说“详情已隐藏”。
5. 利冲关键词构造
每个 PartyInfo 将非空的 Name、ForeignName、FormerName、CreditCode 放入 HashSet<string>(Ordinal),然后以 party.Name 作为 category key。
// 先按业务规则规范名称,保留原值用于证据展示。var normalized = PartySearchTerms.Create( name: party.Name, foreignName: party.ForeignName, formerName: party.FormerName, creditCode: NormalizeCreditCode(party.CreditCode));
// CategoryKey 必须稳定且唯一;不能用可能空白/重复的 Name 覆盖前一方。categoryKeywordMap[normalized.CategoryKey] = normalized.Terms;
// 日志记录哈希/计数和规则版本,不记录完整个人/机构识别信息。audit.RecordInputDigest(normalized.Digest, rules.Version);当前不 Trim/Unicode normalize/case-fold/校验信用代码,也不标记 party role。相同 Name 的两个 party 后者覆盖前者;空 Name 但 foreign/former/credit 有值时以空字符串为 key。
6. 利冲 SearchPerformInput
利冲固定:
- IndexKey=
conflict; - TenantId=当前租户;
- Rule.Scenario=
PreProcessRoleOpposing; - Rule.CategoryKeywordMap=上面的 map;
- ExcludeCaseId 非空时设置 SourceBusinessId 并开启排除。
没有显式 Skip/Take,使用外部类型默认值;没有规则版本、命中阈值、排序、超时预算或最大 party/term 数。结果把所有 hits 映射后,以 matches.Count > 0 得出 HasConflict。
7. HasConflict 不是最终判定
搜索可能有假阳性:同名、简称、历史名称、拼音或弱词剥离;也可能有假阴性:索引延迟、未索引关联方、规则词典缺失。可靠工作流应区分:
PotentialMatch:搜索召回;ReviewedConflict:有权限人员确认;Cleared:确认非冲突并说明理由;Waived/Approved:有冲突但按授权流程 override;Blocked:拒绝接案。
HasConflict=true 只能表示 potential match。当前没有记录/审核模型,甚至 GetConflictRecord 固定 404-like NotFound。
8. 利冲证据模型
商业化利冲记录至少要保存:
- RecordId、TenantId、Case/MatterId、申请人;
- 原始输入的加密/脱敏证据与 canonical digest;
- search index version、watermark、provider version;
- rule/analyzer/segment dictionary version;
- 每个 match 的 owner ID、匹配字段与分值;
- reviewer、decision、reason、override approver;
- created/reviewed/expired 时间;
- 重跑关系与旧结论是否失效。
只保存 HasConflict + MatchCount 的 DTO 也不足以复盘为何命中。
9. ExcludeCaseId 的边界
当前只把 ExcludeCaseId 写入 Query.SourceBusinessId 并打开 Filter.ExcludeSourceBusinessId。需要测试:
- 仅排除当前 Case,不排除同一客户其他 Matter;
- ID 大小写/规范化与索引值一致;
- 空白值不触发;
- 攻击者不能用任意 ID 排除真正冲突;
- 被排除事实进入审计;
- 若 source business id 缺失,不静默扩大排除。
排除条件应由服务器从正在创建/更新的案件上下文得到,不应完全信任客户端字符串。
10. 数据最小化与日志
姓名、曾用名、外文名、信用代码与案件主题可能是个人/商业敏感数据。要求:
- 请求/响应 body 不进普通访问日志;
- tracing tag 不记录完整 keyword/party;
- StoredFields 按 schema 脱敏;
- 利冲审计记录访问者和用途;
- 保留/删除与案件合规策略绑定;
- 调试快照加密、限时并需审批;
- metrics 只记录 term 数、hit 数、耗时和规则版本。
11. 必测场景
- 当前 TenantId 覆盖客户端 TenantId;
- 索引中错误 TenantId 的防护与对账;
- 无场景权限不能通过 unified 搜索 conflict;
- 命中 owner 授权、DataScope 与撤权延迟;
- StoredFields allowlist/脱敏/超长值;
- 授权过滤后的分页和 Total;
- 同名 party、空 Name、Unicode、信用代码;
- ExcludeCaseId 欺骗与边界;
- 规则/词库版本固定与重跑;
- provider timeout/部分结果不得变“无冲突”;
- 索引延迟时 UI 明示 watermark;
- 人工确认、override 与审计不可篡改。
12. 审查命令
# 当前 SearchHitItemProjection 原样复制 StoredFields,且没有 owner authorizer/DataScope。rg -n "StoredFields|SearchHitItemProjection|DataScope|Authoriz.*Filter" \ src/Platform/Search -g '*.cs'
# 检查利冲关键词、trusted tenant 与排除条件。rg -n "BuildCategoryKeywordMap|trustedTenantId|ExcludeSourceBusinessId|HasConflict" \ src/Platform/Search -g '*.cs'
# 当前利冲记录只有固定未就绪 Handler;目标实现应出现 record store、review 和 audit 测试。rg -n "ConflictRecord|ConflictReview|ConflictOverride|PreProcessRoleOpposing" \ src tests -g '*.cs'