Documents 的生产验收不能只看 CRUD 是否返回 200。版本历史会不断放大聚合,成员/ACL 当前没有执行,树可能含坏引用,搜索会截断在 200 条,协作是内存实现。测试与运营门禁必须围绕这些真实风险建立。
1. 当前测试资产
源码已经有较完整的领域单元测试基线:
| 测试文件 | 当前 Fact 数 | 重点 |
|---|---|---|
DocumentTests | 21 | 创建、版本、发布、标签、访问规则、浏览计数 |
KnowledgeBaseTests | 13 | 成员、计数与还原 |
DocumentCategoryTests | 10 | 名称、父级、计数 |
WhiteboardTests | 14 | 数据版本、归档、成员 |
CollaborationSessionTests | 11 | 加入、离开、结束与还原 |
DocumentSearchServiceTests | 13 | 评分、过滤、分页、建议 |
| JSON/QueryShape/Filter/RecycleBin | 18 | 序列化、租户读模型、过滤与回收站 |
| Application read handlers | 4 | 可信租户与读模型委派 |
| Infrastructure architecture | 8 | 依赖与持久化结构规则 |
这些测试证明了局部方法语义,不证明 HTTP 授权、跨 ORM 行为、并发、CAP 事件、缓存、生产数据库索引或多实例协作。
2. 风险驱动测试金字塔
聚合单元测试
- Document 的每个状态 × 每个可变方法;
- 完整快照的 SHA-256、UTF-8 字节数和 Patch 递增;
- 回滚创建新版本且不删除未来历史;
- Unpublish 后 Document 与 Version 发布标记差异;
- KnowledgeBase/Whiteboard/Category 嵌入 JSON 还原失败关闭;
- 树算法对孤儿和环的预期行为;
- 简化 Diff 对头、中、尾、多处分散修改的明确限制。
应用与授权契约测试
- 每个
[GenerateEndpoint]的方法、路由、认证、RateLimit 和 Timeout; - 所有写消息派生的
module.resource.action必须存在于权限目录; - 每个读消息都明确选择
IAuthorizedRequest或文档化的匿名/认证策略; - 当前 User.TenantId 与 EffectiveTenantId 分歧;
- ACL、成员、公开性、创建者和平台管理员的决策表;
- CreateDocument 的 KB/Category/Parent 引用完整性;
- Delete 的权威引用检查与软删除后不可见性。
持久化与并发测试
- SqlSugar 与 EF Core 对四个统一聚合的字段、JSON、软删除与租户行为一致;
- 分类同级名称唯一索引在并发请求下只允许一条;
- Document UpdateContent 的两个并发写入不能丢版本;
- Whiteboard 两个相同 Version 的更新只能一个成功;
- 大型 VersionsJson/Canvas Data 的读取、更新、迁移和备份成本;
- KnowledgeBase/Category 文档计数对账。
3. 授权契约测试示例
[Theory][InlineData("tenant-a", "tenant-b", false)][InlineData("tenant-a", "tenant-a", true)]public async Task Document_Detail_Must_Enforce_Tenant_And_Resource_Access( string callerTenant, string documentTenant, bool sameTenant){ // Arrange:播种一个没有成员的私有文档。 await fixture.SeedDocumentAsync( tenantId: documentTenant, documentId: "doc-secret", isPublic: false, members: []);
using var client = fixture.CreateAuthenticatedClient( tenantId: callerTenant, userId: "outsider");
// Act:以目标租户外或同租户非成员身份读取同一资源。 using var response = await client.GetAsync("/api/v1/documents/doc-secret");
// 跨租户必须隐藏存在性;同租户非成员在目标 ACL 落地后也必须被拒绝。 response.StatusCode.ShouldBe( sameTenant ? HttpStatusCode.Forbidden : HttpStatusCode.NotFound);}这是目标安全契约。以当前实现运行时,同租户非成员会读到文档,因此该测试应先红后绿地驱动资源策略接入。
4. 事件与缓存测试
需要先定义集成事件生产方,再验证完整链路:
当前订阅方抑制异常且 Index/Delete 为 no-op,不能用“消息消费成功”证明索引一致。测试应覆盖重复 EventId、乱序 Update/Delete、索引不可用、重建期间查询、ACL 撤销、缓存失效和 poison message。
5. 可观测性模型
日志不要记录完整 Content、Canvas Data、AccessRules、Members、搜索词或云盘下载 URL。推荐的低基数字段:
| 维度 | 示例 | 说明 |
|---|---|---|
| module/use_case | documents.update_content | 稳定枚举 |
| result | success/validation/conflict/forbidden/dependency | 不用错误消息做标签 |
| tenant_hash | 不可逆短哈希 | 避免原始租户 ID 高基数泄漏 |
| content_type/status | Markdown/Published | 低基数业务维度 |
| version_transition | patch/rollback/publish/archive | 生命周期 |
| dependency | database/cache/search/cloud_drive/realtime | 外部边界 |
关键指标:
documents_command_duration_seconds{use_case,result};documents_version_snapshot_bytes分布;documents_versions_per_document分布;documents_authorization_denied_total{resource,operation};documents_search_candidates_total与截断计数;documents_index_lag_seconds;documents_tree_invalid_reference_total{kind};documents_collaboration_active_sessions、操作拒绝和重放数;documents_count_reconciliation_delta{parent_type}。
6. 告警与运行手册
| 信号 | 可能原因 | 首轮处置 |
|---|---|---|
| 版本写入冲突/失败上升 | 并发编辑、聚合过大、DB 锁 | 查看文档版本数和行大小;停止盲目重试 |
| 详情 P95 持续增加 | includeVersions 被忽略、历史膨胀 | 抽样 VersionsJson 大小,切换专用投影 |
| 树接口超时/栈异常 | ParentId 环或异常深度 | 暂停编辑层级,导出关系并运行环检测 |
| 搜索突然归零 | 读模型故障被缓存为空 | 清除 docs:search 键,检查 DB;修复失败缓存 |
| 搜索漏数 | 候选超过 200 | 比较租户文档量,切换真实索引 |
| 跨租户/非成员可见 | 查询缺授权谓词 | 立即网关封禁相关端点,审计访问并补策略 |
| 白板保存冲突激增 | 全量快照并发、DTO 类型差异 | 检查客户端版本转换和重试策略 |
| 协作内容丢失 | 节点重启/切换 | 当前内存实现无法恢复;停止作为生产编辑入口 |
7. 数据对账
上线定时只读对账,至少报告:
-- 实际表/软删除列名以生成 schema 为准;先在只读副本验证。-- 聚合保存值只用于比较,实际数量从未删除文档权威计算。SELECT kb.Id, kb.DocumentCount AS StoredCount, COUNT(doc.Id) AS ActualCountFROM DocsKnowledgeBase kbLEFT JOIN DocsDocument doc ON doc.TenantId = kb.TenantId AND doc.KnowledgeBaseId = kb.Id AND doc.IsDeleted = 0WHERE kb.IsDeleted = 0GROUP BY kb.Id, kb.DocumentCountHAVING kb.DocumentCount <> COUNT(doc.Id);还应检查孤儿 CategoryId/ParentId/KnowledgeBaseId、跨知识库分类引用、树环、CurrentVersion 不在 VersionsJson、Current Content/Hash 与对应版本不一致、Published 状态缺发布信息和软删除父资源下的活跃子资源。
8. 容量与保留
每个版本保存完整正文,所以总存储近似 正文大小 × 版本数,且每次更新可能读写整段 JSON。生产前必须测量真实 P50/P95/P99 正文和版本数量。
可选演进方向:
- 当前内容留在 Document,版本快照拆成独立表/对象存储;
- 热版本与冷历史分层;
- 以不可变 FileAsset 保存超大附件,Document 只保存引用;
- 版本保留与法律保留策略独立建模;
- 后台压缩/归档必须保持哈希与审计清单。
不要在没有审计、导出和法律保留决策的情况下直接裁剪历史。
9. GA 门禁
阻断上线
- 所有公开 GET 与写入共享资源访问策略,成员/ACL 不再是装饰数据;
- Category/Whiteboard 权限目录补齐,Publish/Manage 动作映射明确;
- Documents Feature 有运行时决策或从产品承诺中移除;
- CreateDocument/Whiteboard/KnowledgeBase 的引用与环检查闭环;
- 文档和分类计数由权威查询或一致性机制维护;
- UpdateContent 与 WhiteboardData 具有数据库级并发保护;
- includeVersions=false 使用轻量投影;
- 搜索不再截断 200 条、不缓存依赖失败为空成功、Highlighter 防 XSS;
- 文件服务不再生成伪 PDF/Word,二进制走 Files;
- 协作若对外开放,必须替换内存/Null Hub 并实现多租户、多实例、持久化与 ACL;
- 协作会话读模型增加 Tenant 谓词;
- 事件生产、幂等索引、失败重试与索引重建有契约测试;
- 双 ORM、HTTP、权限、租户、并发、备份恢复和容量测试全部通过。
可作为后续增强
- 字符级/语义 Diff;
- Major/Minor 版本策略;
- 云盘双向同步;
- 内容预览、转换和全文抽取;
- CRDT 离线协作;
- 浏览分析与推荐。
10. 验证命令
# 模块测试。dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \ --filter 'FullyQualifiedName~BitzOrcas.Unit.Tests.Docs'dotnet test tests/BitzOrcas.Application.Tests/BitzOrcas.Application.Tests.csproj \ --filter 'FullyQualifiedName~Documents'dotnet test tests/BitzOrcas.Architecture.Tests/BitzOrcas.Architecture.Tests.csproj \ --filter 'FullyQualifiedName~DocumentsInfrastructureArchitectureTests'
# 全局扫出仍未接入的关键方法;修复完成后的预期由对应迁移任务定义。rg -n 'HasAccess\(|SetStorage\(|IncrementViewCount\(' src tests -g '*.cs'rg -n 'default-tenant|NullRealtimeCollaborationHub|NullWhiteboardCollaborationHub' \ src/Platform/Documents -g '*.cs'