Skip to content
bitzorcas
中EN

Guide

Documents 读取、搜索、缓存、文件与云盘

解释 Documents 读模型、内存搜索、缓存、索引事件、简化文件服务、Files/Search 边界和云盘适配器现状。

Last updated

Documents 的读取链路以统一聚合和 QueryShape 为事实源;搜索、缓存、文件与云盘则处在不同成熟度。最重要的阅读方法是先确认“谁调用这个服务、是否有 HTTP 端点、数据是否真的落地”,再看接口名称。

1. 读取路径

GET detail/list/tree/version

Query handler

ICurrentUser.User.TenantId

IDocumentsReadModelStore

QueryShape list projection

full aggregate detail

列表使用专用投影,只读取文档摘要、知识库摘要或白板摘要,并把 PageSize 限制为 100。详情则直接读取完整聚合,包括 JSON 集合。所有主资源读取使用 User.TenantId,没有使用 EffectiveTenantId,也没有成员或 ACL 谓词。

查询读模型行为当前边界
文档详情Tenant + Id 读取完整聚合忽略 includeVersions,始终包含版本/ACL
文档列表Tenant + 可选 KB/Category/Status/Type/KeywordKeyword 只匹配 Title/Description
知识库树读取租户全部 KB 后递归无 access filter、无环检测
分类树读取 Tenant + KB 全部分类后在 Application 递归无环检测、无 access filter
白板详情/列表Tenant 谓词详情包含完整 Data;成员不参与过滤
协作会话Id 或 DocumentId + Active没有 Tenant 谓词

2. 内存搜索的真实算法

DocumentSearchService 不是外部全文索引。每次缓存回源会从读模型拿第一页、最多 200 条,然后在进程内筛选、评分、分页。

ReadModelStoreDocumentSearchServiceIAppCacheSearchDocuments handlerReadModelStoreDocumentSearchServiceIAppCacheSearchDocuments handlertenant + filters + keyword + pageGetOrSet(docs:search:..., 2 min)cache miss factoryfirst page, size 200, keyword=nullsummariestags + title/description scoringsort then requested pageDocumentSearchResult

评分规则:

  • 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 会缓存它,形成两分钟假阴性。

索引事件链也不能清缓存:

docs.document.* integration event

DocumentIndexEventHandler

IndexDocument / DeleteIndex

仅记录日志,返回 Success

源码中只找到五个集成事件合同和订阅方,没有找到创建/发布这些 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. 搜索生产化路线

detail remains authoritative

Document transaction

transactional outbox

idempotent indexer

shared Search engine

authorized search API

推荐把当前内存服务定位成开发/小数据 fallback,并让生产索引使用稳定 EventId 幂等、租户分区、ACL 可见性投影、可重试失败和重建索引操作。索引延迟时,详情仍以 Documents 数据库为准。

10. 必测场景

  • 201 个以上文档的搜索召回与 TotalCount;
  • 读模型故障不能缓存空成功;
  • Title/Description 中 HTML、Unicode、超长关键词和缓存键;
  • ACL/成员变更后的索引可见性;
  • includeVersions=false 的实际列投影;
  • PDF/Word 不得返回伪格式;
  • 云盘重复事件、限流、半成功和凭证隔离;
  • 协作会话查询必须添加 Tenant 谓词。

返回 Documents 总览

100%

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