IIManageArchivePort 把厂商 SDK 隔离为五个文档操作。它是翻译层,不是文档管理用例:当前没有 Application 服务为 TenantId、权限、扫描、幂等、审计和对账背书。
1. 文档生命周期
图中只表达 Port 操作,不代表源码维护本地状态机。真实状态由 iManage 决定,当前平台不持久化 shadow state。
2. 租户客户端
每个操作调用 IManageClientFactory.Create(tenantId) 并释放客户端。这为多实例路由提供基础,但 TenantId 仍是普通请求字段。
// ① TenantId 只从认证上下文取,不接受 command.TenantId。string tenantId = currentUser.User.TenantId;
// ② owner 验证目标 folder 与当前业务记录的关系。await policy.AuthorizeUploadAsync(tenantId, command.CaseId, command.FolderId, ct);
// ③ 已扫描文件才进入连接器。await using Stream stream = await files.OpenCleanReadAsync(command.FileAssetId, ct);Result<IManageDocumentDto> result = await archive.UploadDocumentAsync( new IManageUploadRequest(tenantId, command.FolderId, command.Name, command.Extension, command.Description, command.CustomFields), stream, ct);具体 DTO 构造参数应以源码版本为准;关键点是 TenantId 和 stream 的可信来源,而不是让 API 直接映射 Port DTO。
3. 上传边界
Adapter 验证租户、文件夹、名称和扩展名的基本非空,但没有验证:
- stream 非 null、可读或剩余长度;
- 文件大小上限;
- magic bytes、MIME 与扩展名一致;
- 恶意内容扫描状态;
- 扩展名 allowlist;
- 重复请求/idempotency key;
- 供应商超时后的未知结果。
非 seekable stream 的大小被报告为 0;null stream 会在读取 CanSeek 时抛错。调用方必须预验证并提供明确的内容长度。
4. 下载的内存模型
SDK 返回流后,Adapter 把全部内容复制到 MemoryStream 再返回。这简化了客户端生命周期,却把文件大小直接变成进程连续内存压力。
当前没有最大下载大小、流式 backpressure 或临时对象存储。CopyToAsync 的 IO 异常也不在只捕获 IManageException 的 catch 内。
GA 可选择:受限小文件缓冲;直接流式且延长客户端生命周期;或先写入受控临时对象存储。无论选择哪种,都要限定字节、超时、并发和释放所有权。
5. 下载用例示例
// ① 文档权限和租户范围先由 owner 判断。await policy.AuthorizeDownloadAsync(tenantId, documentId, version, ct);
Result<Stream> downloaded = await archive.DownloadDocumentAsync(tenantId, documentId, version, ct);if (downloaded.IsFailure) return Problem(downloaded.Error);
// ② 当前返回 MemoryStream,调用方拥有并必须释放。await using Stream content = downloaded.Value!;
// ③ 生产实现仍要执行下载上限、审计和安全响应头策略。return await responseWriter.WriteAttachmentAsync(content, safeFileName, ct);6. 签出与并发
Checkout 是远端状态变更。当前请求没有 idempotency key、expected version 或本地操作记录;网络超时后无法判断供应商是否已锁定。
重复 Checkout 的冲突语义完全由 SDK 错误决定,没有规范化为 AlreadyCheckedOut/OwnedByOther/UnknownOutcome。重试前应查询远端状态,而不是盲目再次执行。
7. 签入语义
Port 注释称 null stream 仅释放锁,Adapter 实际把 null 转成 Stream.Null 并调用 SDK CheckinAsync。仓库内没有合同测试证明厂商把空流解释为“只释放锁”。
在验证前,文档不能把该行为当保证。更安全的契约应拆分 UndoCheckout/ReleaseLock 与 CheckinNewVersion,避免以空流编码不同命令。
签入也没有 expected checked-out user/version、内容哈希或幂等键。owner 应保存 operation ID、远端文档 ID、开始时间和最终结果用于对账。
8. 自定义字段
Adapter 只识别 Custom1 到 Custom30,大小写不敏感;未知 key 被静默忽略。静默丢弃会让调用者误以为元数据已归档。
Application 应使用强类型映射或租户配置 schema,拒绝未知/重复字段,限制长度和敏感数据,并记录映射版本。
9. 时间与 DTO 映射
文档时间用 new DateTimeOffset(dateTime, TimeSpan.Zero) 映射,隐含供应商 DateTime 已是 UTC。若 Kind 为 Local,该构造可能抛异常;若 Unspecified,则只是把未证实时间标为 UTC。
需要用 SDK 契约和合同测试确定时区,显式 normalize。历史排序、审计和 SLA 都依赖这一点。
10. 错误分类
Adapter 只捕获 IManageException,错误 Code 拼成 ToolConnectors.iManage.ProviderFailed:{vendorCode} 并返回原始消息。
问题包括动态错误码破坏客户端稳定性、供应商信息泄漏、IO/取消/映射异常逃逸。建议稳定分类:Unavailable、UnauthorizedToProvider、NotFound、Conflict、RateLimited、Timeout、UnknownOutcome、ProviderFailed;厂商码只进受限诊断字段。
11. 审计、幂等与对账
Adapter 有意不写审计,但当前不存在拥有这些用例的 Application 层。因此系统暂时没有“谁为哪个案件上传/签出/签入”的统一审计。
状态变更应在调用前预留 operation record,使用业务 idempotency key;成功后保存远端 ID/版本;超时标 Unknown;后台通过文档详情/历史对账;审计引用同一 operation ID。
12. 测试与运维
合同测试覆盖租户路由、空/不可读/非 seekable/巨大 stream、恶意扩展、Custom 字段、时区 Kind、SDK 错误分类、下载释放、取消、Checkout 冲突、Checkin null 语义和未知结果对账。
监控调用数、延迟、字节、冲突、限流、未知结果、对账积压、凭据错误与租户客户端创建失败;日志不记录文件内容、名称、Custom 值或 token。
上线评审还应逐项回答:
- 单文件和单租户的字节上限是多少;
- 下载中断时谁释放供应商客户端与本地流;
- Checkout 超时由哪个任务对账;
- 重复 operation ID 返回原结果还是冲突;
- 供应商历史时间按哪个时区解释;
- 凭据轮换是否影响在途客户端;
- 文档删除和保留由哪个系统拥有;
- sandbox 与生产 SDK 版本是否一致。
rg -n "UploadDocumentAsync|DownloadDocumentAsync|CheckoutAsync|CheckinAsync|Custom[0-9]" \ src/Platform/ToolConnectors -g '*.cs'