Skip to content
bitzorcas
中EN

Concept

Files 完成上传与内容完整性

深入 FinalizeUpload、对象元数据权威性、状态迁移、事务、事件、幂等与恶意内容隔离。

Last updated

Finalize 是 Files 最重要的信任转换:它把“客户端声称已经上传”的对象,转换为“允许业务绑定和下载”的资产。当前实现能校验大小、MIME 和一个哈希值,但哈希与 MIME 是否真正来自存储端取决于 Provider,因此不能把成功状态等同于内容已经经过安全验证。

1. 请求合同

完成上传请求
POST /api/files/019c64e67fc87db8b7ca748f5442f91a/finalize HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"contentHash": "72f7d0f8c6d6c0b51f5f3e4fa76c76bc9f70a10fb149c010eb2f2e330df8f123",
"size": 48231,
"contentType": "application/pdf"
}

路由中的 FileId 由生成式端点映射到 Command。消息使用 Update 动作,并指定 FileHandshake 超时策略;这只限制 API 请求时长,不提供重试、幂等或存储回滚。

2. 当前处理路径

Event PublisherFileAssetIFileStorageCommand RepositoryFinalize HandlerClientEvent PublisherFileAssetIFileStorageCommand RepositoryFinalize HandlerClientfileId + client metadataFindAsync(fileId)tenant-filtered FileAssetGetMetadataAsync(storageKey)length / type? / sha? / etag?Finalize(resolved metadata)success or typed conflictSaveAsync(asset)PublishAsync(FileAssetSummary)Finalized summary

对象在这条路径之前已经写入外部存储。数据库事务可以回滚资产与事件发布,但不能自动回滚对象字节。

3. 元数据权威矩阵

处理器的实际合并规则是:

FinalizeUpload 的实际元数据选择逻辑
// 客户端值先作为回退值。
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;
ProviderSizeContentTypeSHA-256ETag实际风险
Local文件系统长度nullnullnull类型与哈希来自客户端,且 HTTP presign 流程本身不可用
S3-compatibleHEAD ContentLengthHEAD ContentType仅 x-amz-meta-sha256HEAD ETag上传者可提交 user metadata;缺失时哈希回退客户端
Connector bridge流 Lengthnullnullnull类型与哈希完全回退客户端
Unavailable抛出不支持不可用不可用不可用handler 返回稳定失败

ETag 不等于 SHA-256,尤其在 Multipart 上传中不能作为业务内容哈希。当前 S3 适配器正确地没有做这种等价推断。

4. 聚合如何校验

FileAsset.ValidateObservedMetadata 按顺序执行:

  1. observed hash 与 content type 必须非空;
  2. 初始 ContentHash 非空时才比较哈希;PendingUpload 初始为空,因此接受第一次 observed hash;
  3. observed size 必须与创建会话时声明的 Size 完全相同;
  4. ContentType 使用不区分大小写的字符串相等比较;
  5. 所有校验通过后才修改状态、ContentHash、ETag 和 FinalizedAt。

这能防止失败请求留下半推进的聚合,但不能证明 observed hash 是可信计算结果。

5. 状态迁移细节

MarkUploadedFinalizehandler-internal first stepsame handler callAlreadyFinalizedAlreadyDeleted

PendingUpload

Uploaded

Finalized

Deleted

聚合 Finalize 对 PendingUpload 做两步迁移:先 MarkUploaded,再 Finalize。直接调用 FileAssetState.Finalize() 时 PendingUpload 会返回 File.NotYetUploaded;调用聚合则允许一次完成两步。这是公开领域类型之间需要特别理解的差异。

6. 重复请求与并发

第二次 finalize 返回 File.AlreadyFinalized,不是返回第一次结果。当前消息没有幂等键,也没有版本列或显式乐观并发 Token。

客户端对 finalize 结果的安全处理
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.MetadataVerificationUnavailableProvider 不支持 metadata否
File.ObjectNotFoundProvider 抛 FileNotFound否
File.ObservedMetadataRequiredhash/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. 扫描与隔离的目标流程

是否

上传到 quarantine key

可信 metadata + 服务端 SHA-256

恶意内容 / DLP / 格式验证

全部通过?

复制/标记到 trusted zone

FileAsset Finalized

Quarantined / Rejected
保留证据并清理

当前状态机没有 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. 测试示例

Provider 无可信哈希时必须拒绝的目标回归测试
[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. 源码与验证

Terminal window
# 运行当前状态机和 handler 测试。
dotnet test tests/BitzOrcas.Unit.Tests \
--filter FullyQualifiedName~FileAssetFinalizeTests
dotnet 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'

上一页:上传会话 · 下一页:下载授权与 Owner Policy

100%

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