Search 同时使用五条手写 Minimal API 和四条 [GenerateEndpoint] 路由。授权最终由 request 的 ResourceDescriptor + AuthorizationAction 派生,注释里写“需要 manage/check”不会改变运行时权限。
1. 端点总表
| 方法 | 路由 | 实现 | 请求动作 | 实际权限 |
|---|---|---|---|---|
| POST | /api/search/unified | 手写转换 | View | search.search.view |
| POST | /api/search/conflict/check | 手写转换 | Search | search.conflict.search |
| POST | /api/search/segment/words | 手写枚举校验 | Create | search.index.create |
| DELETE | /api/search/segment/words | 手写枚举校验 | Delete | search.index.delete |
| GET | /api/search/segment/words/{segmentType} | 手写枚举校验 | View | search.index.view |
| GET | /api/search/conflict/records/{recordId} | generated | View | search.conflict.view |
| GET | /api/search/index/catalog | generated | View | search.index.view |
| GET | /api/search/index/{indexKey}/statistics | generated | View | search.index.view |
| POST | /api/search/index/{indexKey}/rebuild | generated | Manage | search.index.manage |
手写 group 统一 RequireAuthorization()、userPolicy rate limit;unified/conflict/segment 写使用 StandardCommand timeout,segment list 使用 ShortRead。Generated route 经过统一授权管道,但源码没有在 request 上声明 Search 专用 timeout policy。
2. 统一搜索请求
POST /api/search/unified HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "indexKey": "conflict", "keyword": "海岚科技", "skip": 0, "take": 20}当前 API model 默认 Skip=0、Take=20,但没有规则拒绝:
- 空白/未知 IndexKey;
Skip < 0;- Take≤0 或超大;
- 过长 Keyword;
- wildcard/query syntax 与控制字符;
- 深分页导致的高成本窗口。
Handler 把同一个 keyword 写入 Name、Subject、Content,不 trim,也不选择 culture/analyzer。provider 如何处理空白和特殊字符取决于外部包,公开协议不能依赖未固定的隐式行为。
3. 利冲请求
{ "tenantId": "client-supplied-and-ignored", "parties": [ { "name": "海岚科技有限公司", "foreignName": "Hailan Technology", "formerName": "海岚实业", "creditCode": "91310000MA00000000" } ], "excludeCaseId": "case-2026-0042"}TenantId 保留在 DTO/API 中却被 Handler 覆盖为当前租户。这避免了跨租户读取,但会让 SDK 使用者误以为字段有效。应删除客户端 TenantId,或清晰标记 deprecated/ignored,避免审计和调用误解。
Parties 没有 request rule:null collection 会在枚举时异常;空列表直接返回 HasConflict=false;Name 虽声明 non-null,但 runtime JSON 可提交 null;重复 Name 会覆盖 category map 中前一个集合;Name 全空白但其他字段有值时仍以空白 Name 作字典 key。利冲高价值操作不能用这些隐式边界。
4. SegmentType 与 Word
端点在发送 Mediator 前用 Enum.IsDefined 校验 0/1/2,非法值直接返回匿名 400 对象:
{ "code": "Search.Segment.InvalidType", "message": "无效的分词类型: 9"}这条错误没有走统一 Result/Error → Problem Details,响应 media type/traceId/扩展字段可能与其他端点不一致。Word 只在 Handler 拒绝 null/空白并 Trim;不限制长度、Unicode 规范化、大小写、控制字符或业务词类型。
public static class SearchErrors{ public static readonly Error IndexUnknown = Error.NotFound("Search.Index.Unknown", "搜索场景不存在或未启用。");
public static readonly Error PageInvalid = Error.Validation("Search.Page.Invalid", "搜索窗口超出范围。");
public static readonly Error KeywordTooLong = Error.Validation("Search.Keyword.TooLong", "搜索关键字过长。");}
// IndexKey 只能从已启用目录解析,不能让用户任意探测物理索引名。var catalog = await catalogStore.GetAsync(request.IndexKey, cancellationToken);if (catalog is null || catalog.Status != IndexStatus.Active) return SearchErrors.IndexUnknown;
// 交互式搜索使用有界窗口;批量导出另建异步契约。if (request.Skip < 0 || request.Take is < 1 or > 100) return SearchErrors.PageInvalid;
// 先 Unicode normalize/trim,再按 UTF-8 byte 与 rune 数设置上限。if (Normalize(request.Keyword).Length > 200) return SearchErrors.KeywordTooLong;5. 权限目录与运行时漂移
治理目录:
search.search.view;search.conflict.search;search.conflict.view;search.index.manage;search.index.view。
View 类端点、利冲 check 与重建都与目录一致。仍漂移的是弱词写:
// 利冲 check 实际派生与目录一致。new ResourceDescriptor("search", "conflict");AuthorizationAction.Search; // => search.conflict.search
// 重建声明 Manage,与目录一致;弱词写分别声明 Create/Delete。new ResourceDescriptor("search", "index");AuthorizationAction.Manage; // => search.index.manageAuthorizationAction.Create; // => search.index.createAuthorizationAction.Delete; // => search.index.deleteAuthorizationAction 没有 Check 动作,却有 Manage。修复路线:把弱词 create/delete 统一为 AuthorizationAction.Manage,或分权并迁移目录。需要一起修改 seed、角色 grant、缓存、升级映射和端点测试。
6. Feature 目录没有运行时门禁
SearchFeatures.Index = "search.index" 以 DefaultEnabled=false 进入治理 catalog。中央 FeaturePolicyEvaluator.ModuleFeatureMap 只有 tickets/chat/workflow,没有 search;Search request 因此得到 Neutral,不会查询租户 entitlement。
如果 index management 与 query 是不同套餐能力,一个 search.index 也可能太粗。推荐至少区分 query、conflict、index-management,或明确全模块只使用一个 entitlement。无论粒度如何,必须在统一 evaluator 或用例 gate 中真正执行。
7. 目录与统计的作用域
GetIndexStatistics 以当前 TenantId 调 engine,是租户级统计。GetIndexCatalog 读取全局 catalog,没有 TenantId,也没有只返回租户启用场景的过滤。拥有 search.index.view 的租户用户可能看到 DirectoryName、EntityType、全局 DocumentCount、LastRebuiltAt 与 Version。
目录详情偏运维/平台信息,应决定它属于:平台管理员;租户管理员的裁剪视图;或仅内部 health endpoint。不要用同一个 index.view 同时承担搜索使用权与物理目录可见权。
8. 索引场景级授权
UnifiedSearch 接受任意 IndexKey,但 ResourceDescriptor 固定为 search/search,没有把 IndexKey 带入 resource id/attributes。只要有统一搜索权限,就能尝试 conflict、keyword-search 或未来索引。
可选模型:
- 每 IndexKey 权限:
search.index.{key}.query; - catalog 保存 RequiredPermission/DataClassification;
- authorization attribute 带 IndexKey、业务类型与敏感级别;
- 对利冲等高敏场景只开放专用 endpoint,不允许 unified 绕过。
即使场景授权通过,命中仍需源资源 DataScope。
9. 稳定错误契约
当前稳定错误只有:
SegmentWord.EmptyWord(Failure);ConflictRecord.PendingCasesModule(NotFound)。
缺少 IndexUnknown、IndexUnavailable、QueryInvalid、WindowTooLarge、ProviderTimeout、RebuildConflict 等分类。外部 provider exception 直接穿过 Handler,最终映射是否稳定取决于全局异常处理。
10. Endpoint 测试矩阵
- 每个 catalog grant 与实际 action 的 200/403;
- Feature enabled/disabled/unavailable;
- 认证、rate limit 与 timeout;
- IndexKey allowlist 和场景级权限;
- Skip/Take/Keyword 边界;
- Parties null/empty/重复/Unicode/超长;
- 请求 TenantId 不影响 effective tenant;
- SegmentType 400 也使用统一 Problem Details;
- generated/manual route OpenAPI 一致;
- provider unavailable 不被转成空结果。
11. 审查命令
# 对照端点动作与治理目录;conflict 与 rebuild 已与目录一致,弱词 create/delete 仍漂移。rg -n "AuthorizationAction\.|search\.(search|conflict|index)\." \ src/Platform/Search src/Hosts/BitzOrcas.Api/Endpoints/SearchEndpoints.cs -g '*.cs'
# Search Feature 当前不在统一 module map。rg -n "search\.index|ModuleFeatureMap|\[\"search\"\]" src tests -g '*.cs'
# 当前没有 Search request rule;补齐后应出现每个高风险请求的规则和测试。rg -n "IRequestRule<.*(UnifiedSearch|CheckConflict|RebuildIndex|Segment)" \ src/Platform/Search tests -g '*.cs'