Documents 的读取链路以统一聚合和 QueryShape 为事实源;搜索、缓存、文件与云盘则处在不同成熟度。最重要的阅读方法是先确认“谁调用这个服务、是否有 HTTP 端点、数据是否真的落地”,再看接口名称。
1. 读取路径
列表使用专用投影,只读取文档摘要、知识库摘要或白板摘要,并把 PageSize 限制为 100。详情则直接读取完整聚合,包括 JSON 集合。所有主资源读取使用 User.TenantId,没有使用 EffectiveTenantId,也没有成员或 ACL 谓词。
| 查询 | 读模型行为 | 当前边界 |
|---|---|---|
| 文档详情 | Tenant + Id 读取完整聚合 | 忽略 includeVersions,始终包含版本/ACL |
| 文档列表 | Tenant + 可选 KB/Category/Status/Type/Keyword | Keyword 只匹配 Title/Description |
| 知识库树 | 读取租户全部 KB 后递归 | 无 access filter、无环检测 |
| 分类树 | 读取 Tenant + KB 全部分类后在 Application 递归 | 无环检测、无 access filter |
| 白板详情/列表 | Tenant 谓词 | 详情包含完整 Data;成员不参与过滤 |
| 协作会话 | Id 或 DocumentId + Active | 没有 Tenant 谓词 |
2. 内存搜索的真实算法
DocumentSearchService 不是外部全文索引。每次缓存回源会从读模型拿第一页、最多 200 条,然后在进程内筛选、评分、分页。
评分规则:
- Title 包含关键词:+0.7;
- Title 以前缀开始:再 +0.2;
- 标题越短,增加最多约 0.09 的密度分;
- Description 包含关键词:+0.3;
- 最终分数封顶 1.0。
正文 Content 不参与搜索。第 201 条及以后不会进入候选集,所以 TotalCount 是“首批 200 条中的匹配数”,不是租户全部文档的匹配数。
3. 搜索不是 HTTP 表面
SearchDocumentsQuery 和 GetSearchSuggestionsQuery 实现 IAuthorizedRequest,但文件中没有 [GenerateEndpoint]。因此下列代码只能用于进程内 Mediator 调用,不能假设 /api/v1/documents/search 已存在:
// 该消息只通过 Mediator 调用;当前没有对应的生成式 HTTP 路由。var result = await mediator.Send( new SearchDocumentsQuery( Keyword: "退款", KnowledgeBaseId: "kb-support", CategoryId: null, ContentType: ContentType.Markdown, Status: DocumentStatus.Published, Tags: ["客服"], PageIndex: 1, PageSize: 20), cancellationToken);
// 依赖失败必须保留失败语义,不能当成真正的空命中。if (result.IsFailure) return Result.Failure<SearchView>(result.Error);
// 结果只覆盖读模型第一页的 200 个候选文档。return SearchView.From(result.Value!);若产品需要 HTTP 搜索,应显式生成端点、补资源访问过滤、输入长度限制、稳定排序、真实总数和契约测试;不能只在前端发明一个路由。
4. 高亮与 XSS 边界
高亮器在原始 Title/Description 中直接插入 <mark>,不会先做 HTML 编码:
输入标题: <img src=x onerror=alert(1)>退款说明高亮结果: <img src=x onerror=alert(1)><mark>退款</mark>说明如果 UI 通过 innerHTML/raw HTML 渲染,高亮结果会成为 XSS 载体。安全做法是让服务返回结构化片段或位置范围:
type HighlightPart = { text: string; matched: boolean };
export function renderTitle(parts: HighlightPart[]): HTMLElement { const container = document.createElement("span");
// 结构化片段决定标签边界,原始内容永远只作为文本节点。 for (const part of parts) { // textContent 会编码不可信标题;匹配片段只决定标签,不提供 HTML。 const node = part.matched ? document.createElement("mark") : document.createElement("span"); node.textContent = part.text; container.append(node); }
return container;}在服务端未改造前,前端应解析受限 <mark> 或先整体编码再由可信逻辑插入标签,绝不能直接信任返回字符串。
5. 缓存语义与失效缺口
搜索缓存 TTL 是两分钟,Key 包含 TenantId、原始 Keyword、KB、Category、枚举、排序后的原始 Tags 和页码。它会泄漏搜索词到缓存键/诊断工具,也可能被超长输入放大。
读模型失败时 ExecuteSearchAsync 返回空结果。虽然注释声称“不向缓存写入错误”,实际空结果是 factory 的正常返回值,外层 GetOrSetAsync 会缓存它,形成两分钟假阴性。
索引事件链也不能清缓存:
源码中只找到五个集成事件合同和订阅方,没有找到创建/发布这些 IntegrationEvent 的生产方。聚合只在 Publish/Archive 产生领域事件;没有审计到领域事件到这五个集成事件的桥接。订阅方还会抑制非取消异常以避免重试风暴。
DocumentCacheService 提供 10 分钟详情缓存,Key 按 Tenant + DocumentId 隔离,但没有 query/command handler 调用它。因此当前详情读取不走这层缓存,也不存在业务链路上的失效问题——因为它尚未接入。
6. 文档文件服务不是 Files 模块
内部 DocumentFileService 当前行为:
| 方法 | 实际行为 | 不能承诺的能力 |
|---|---|---|
| UploadAsync | 校验少量参数、记 Warning、返回流长度 | 不保存字节、不更新 StorageKey/FileSize |
| DownloadAsync | 读取 Document.Content 的 UTF-8 字节 | 不读取对象存储、不做格式转换 |
| PDF/Word 下载 | 给文本字节标记 PDF/DOCX MIME 和扩展名 | 生成的内容不是有效 PDF/DOCX |
// 1. 二进制先走 Files 的 upload-session + finalize,得到可绑定 FileId。var finalizedFile = await files.UploadAndFinalizeAsync(source, cancellationToken);
// 2. Documents 只保存业务关系,例如独立附件表或受控元数据。var attachment = DocumentAttachment.Create( documentId, finalizedFile.FileId, displayName: "退款凭证.pdf");
// 3. 下载时仍由 Files 执行资产状态、租户和 Owner 策略。return await files.GetDownloadAccessAsync(attachment.FileId, cancellationToken);如果需要把 Markdown 渲染为 PDF,应引入明确的 Export/Rendering 用例,生成真实二进制后交给 Files;不要复用当前 DocumentFileService.DownloadAsync。
7. StorageKey 与 ViewCount
Document.SetStorage 和 IncrementViewCount 是可调用领域方法,但源码搜索只找到聚合定义以及 IncrementViewCount 的单元测试,没有应用处理器调用。因此:
- StorageKey/FileSize 可能长期保持默认值;
- 读取详情不会增加 ViewCount;
- 列表中的 ViewCount 不能当作真实浏览指标;
- 不应基于该字段做推荐、计费或 SLA 报表。
浏览统计更适合通过独立、可去重的事件/分析管道维护,避免每次读取争用文档聚合行。
8. 云盘端口
ICloudSyncPort 暴露 Upload、ListFiles、Search 和 ProviderName。CloudDriveAdapter 把 ICloudDriveProvider 的六种供应商模型映射为平台中性 DTO,只保留 Id、Name、Size、ContentType、DownloadUrl、ModifiedTime。
当前 adapter 没有 DI 注册标注,宿主也没有审计到 ICloudSyncPort 注册;Documents 用例没有调用该端口。它是可组合的基础接缝,不是“已完成云同步”。真正同步仍缺:
- 连接配置与租户/用户凭证归属;
- 远端路径和 Document/FileId 映射;
- 增量游标、冲突策略、删除传播;
- 幂等键、重试、限流、断点和对账;
- 下载 URL 泄漏控制与审计;
- 后台调度和运营状态。
9. 搜索生产化路线
推荐把当前内存服务定位成开发/小数据 fallback,并让生产索引使用稳定 EventId 幂等、租户分区、ACL 可见性投影、可重试失败和重建索引操作。索引延迟时,详情仍以 Documents 数据库为准。
10. 必测场景
- 201 个以上文档的搜索召回与 TotalCount;
- 读模型故障不能缓存空成功;
- Title/Description 中 HTML、Unicode、超长关键词和缓存键;
- ACL/成员变更后的索引可见性;
- includeVersions=false 的实际列投影;
- PDF/Word 不得返回伪格式;
- 云盘重复事件、限流、半成功和凭证隔离;
- 协作会话查询必须添加 Tenant 谓词。