Finalize 是 Files 最重要的信任转换:它把“客户端声称已经上传”的对象,转换为“允许业务绑定和下载”的资产。当前实现能校验大小、MIME 和一个哈希值,但哈希与 MIME 是否真正来自存储端取决于 Provider,因此不能把成功状态等同于内容已经经过安全验证。
1. 请求合同
POST /api/files/019c64e67fc87db8b7ca748f5442f91a/finalize HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "contentHash": "72f7d0f8c6d6c0b51f5f3e4fa76c76bc9f70a10fb149c010eb2f2e330df8f123", "size": 48231, "contentType": "application/pdf"}路由中的 FileId 由生成式端点映射到 Command。消息使用 Update 动作,并指定 FileHandshake 超时策略;这只限制 API 请求时长,不提供重试、幂等或存储回滚。
2. 当前处理路径
对象在这条路径之前已经写入外部存储。数据库事务可以回滚资产与事件发布,但不能自动回滚对象字节。
3. 元数据权威矩阵
处理器的实际合并规则是:
// 客户端值先作为回退值。var verifiedHash = request.ContentHash;var verifiedSize = request.Size;var verifiedContentType = request.ContentType;
var metadata = await storage.GetMetadataAsync(asset.StorageKey, cancellationToken);
// Length 总是取 Provider metadata;其余字段为空时回退客户端。verifiedSize = metadata.ContentLength;verifiedContentType = metadata.ContentType ?? request.ContentType;verifiedHash = metadata.Sha256 ?? request.ContentHash;var verifiedETag = metadata.ETag;| Provider | Size | ContentType | SHA-256 | ETag | 实际风险 |
|---|---|---|---|---|---|
| Local | 文件系统长度 | null | null | null | 类型与哈希来自客户端,且 HTTP presign 流程本身不可用 |
| S3-compatible | HEAD ContentLength | HEAD ContentType | 仅 x-amz-meta-sha256 | HEAD ETag | 上传者可提交 user metadata;缺失时哈希回退客户端 |
| Connector bridge | 流 Length | null | null | null | 类型与哈希完全回退客户端 |
| Unavailable | 抛出不支持 | 不可用 | 不可用 | 不可用 | handler 返回稳定失败 |
ETag 不等于 SHA-256,尤其在 Multipart 上传中不能作为业务内容哈希。当前 S3 适配器正确地没有做这种等价推断。
4. 聚合如何校验
FileAsset.ValidateObservedMetadata 按顺序执行:
- observed hash 与 content type 必须非空;
- 初始 ContentHash 非空时才比较哈希;PendingUpload 初始为空,因此接受第一次 observed hash;
- observed size 必须与创建会话时声明的 Size 完全相同;
- ContentType 使用不区分大小写的字符串相等比较;
- 所有校验通过后才修改状态、ContentHash、ETag 和 FinalizedAt。
这能防止失败请求留下半推进的聚合,但不能证明 observed hash 是可信计算结果。
5. 状态迁移细节
聚合 Finalize 对 PendingUpload 做两步迁移:先 MarkUploaded,再 Finalize。直接调用 FileAssetState.Finalize() 时 PendingUpload 会返回 File.NotYetUploaded;调用聚合则允许一次完成两步。这是公开领域类型之间需要特别理解的差异。
6. 重复请求与并发
第二次 finalize 返回 File.AlreadyFinalized,不是返回第一次结果。当前消息没有幂等键,也没有版本列或显式乐观并发 Token。
var response = await api.PostAsJsonAsync( $"/api/files/{fileId}/finalize", new { contentHash, size, contentType }, cancellationToken);
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FileAssetSummaryDto>(cancellationToken);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetailsDto>(cancellationToken);
// 当前 AlreadyFinalized 只能说明数据库状态已完成;// 若要恢复第一次响应,应重新读取受授权的资产摘要,而不是盲目重发。if (problem?.Code == "File.AlreadyFinalized") return await fileAssets.GetAuthorizedSummaryAsync(fileId, cancellationToken);
throw new FileFinalizeException(problem?.Code ?? "File.Unknown");两个并发请求都读到 Pending 时,正确性依赖仓储/事务冲突行为;现有测试只覆盖顺序状态机,没有真实数据库并发 finalize 契约测试。
7. 错误语义
| 错误 | 触发条件 | 状态是否推进 |
|---|---|---|
File.NotFound / repository error | 有效租户内查不到资产 | 否 |
File.MetadataVerificationUnavailable | Provider 不支持 metadata | 否 |
File.ObjectNotFound | Provider 抛 FileNotFound | 否 |
File.ObservedMetadataRequired | hash/type 解析后为空 | 否 |
File.HashMismatch | 已登记 hash 与 observed 不同 | 否 |
File.SizeMismatch | 对象长度与声明大小不同 | 否 |
File.ContentTypeMismatch | 对象类型与声明不同 | 否 |
File.AlreadyFinalized | 重复完成 | 保持 Finalized |
File.AlreadyDeleted | 已删除后完成 | 保持 Deleted |
通用 Result 映射负责 Problem Details。不要在消费端通过本地化 message 分支,使用稳定 Code。
8. UploadExpiresAt 的真实边界
创建会话会保存 clock.UtcNow.AddMinutes(30),但 Finalize Handler 不读取 UploadExpiresAt。因此只要对象存储 URL 仍可用或字节已经写入,资产在数据库过期时间之后仍可完成。
目标检查必须使用应用时钟,并明确边界等号:
public static class FileErrors{ public static readonly Error UploadSessionExpired = Error.Conflict( FileErrorCodes.UploadSessionExpired, "上传会话已经过期,请重新创建。");}
// 这是建议补齐的领域门禁,当前源码尚未实现。if (asset.UploadExpiresAt is not null && clock.UtcNow >= asset.UploadExpiresAt){ return Result.Failure<FileAssetSummary>(FileErrors.UploadSessionExpired);}还要保证对象存储 URL 的实际过期与数据库值一致。连接器 Provider 当前不接受按请求 TTL,不能仅凭 DTO 推断一致。
9. 扫描与隔离的目标流程
当前状态机没有 Quarantined、Scanning 或 Rejected。若扫描异步化,不能在扫描完成前把资产标成 Finalized,否则业务模块会合法下载未审查内容。
10. 事件与事务
处理器在 repository.SaveAsync 成功后执行 fileFinalizedPublisher.PublishAsync(asset.ToSummary())。在 CAP Outbox 配置正确时,数据库写入与事件记录由事务管道协调;Shell 默认 Publisher 只提供可解析的失败关闭/替代行为。
需要固定以下失败组合:
| 失败点 | 数据库 | 对象 | 事件 | 恢复动作 |
|---|---|---|---|---|
| metadata 读取失败 | Pending | 可能存在 | 无 | 修复存储后重试 |
| 聚合校验失败 | Pending | 存在但不匹配 | 无 | 隔离/删除对象 |
| Save 失败 | 回滚 | 存在 | 无 | 幂等重试或孤儿清理 |
| Publish 失败且同事务 | 回滚 | 存在 | 无 | Outbox/命令重试 |
| 进程在提交后崩溃 | Finalized | 存在 | 取决于 Outbox | 由 Outbox 恢复投递 |
11. 测试示例
[Fact]public async Task Finalize_should_not_trust_client_hash_when_provider_has_no_sha256(){ // 构造长度和类型可信、但缺少服务端 SHA-256 的 Provider 响应。 storage.GetMetadataAsync(asset.StorageKey, Arg.Any<CancellationToken>()) .Returns(new FileObjectMetadata( asset.StorageKey, ContentLength: asset.Size, ContentType: asset.ContentType, ETag: "etag-1", Sha256: null, LastModified: clock.UtcNow));
var result = await handler.Handle( new FinalizeUploadCommand(asset.FileId, "client-controlled", asset.Size, asset.ContentType), CancellationToken.None);
// 目标行为:缺少服务端校验能力时失败关闭,不能回退信任请求哈希。 result.IsFailure.ShouldBeTrue(); result.Error.Code.ShouldBe(FileErrorCodes.MetadataVerificationUnavailable);}这条测试描述生产目标,当前实现会接受客户端哈希,加入前应先修改合同与实现。
12. 源码与验证
# 运行当前状态机和 handler 测试。dotnet test tests/BitzOrcas.Unit.Tests \ --filter FullyQualifiedName~FileAssetFinalizeTestsdotnet test tests/BitzOrcas.Application.Tests \ --filter FullyQualifiedName~FinalizeUpload
# 证明哈希回退与 UploadExpiresAt 未消费的现状。rg -n "metadata.Sha256 \?\?|UploadExpiresAt" src/Platform/Files -g '*.cs'
# 查找扫描器与隔离状态;当前预期没有命中。rg -n "Malware|Virus|Quarantine|Scanning|Rejected" src/Platform/Files -g '*.cs'