Skip to content
bitzorcas
中EN

Guide

Files 上传会话与客户端直传

讲解 CreateUploadSession 的请求合同、可信字段、预签名上传、客户端实现、失败补偿和安全加固。

Last updated

CreateUploadSession 的任务不是接收文件字节,而是登记一条 PendingUpload 资产,并向已授权调用者签发一个临时 PUT 能力。客户端随后直接把字节发送到对象存储,避免 API 进程代理大文件流量。

1. 请求与响应合同

创建上传会话
POST /api/files/upload-session HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"ownerType": "user",
"fileName": "invoice-2026-07.pdf",
"contentType": "application/pdf",
"size": 48231,
"visibility": "private"
}

成功响应中的 fileId 与 storageKey 由服务端生成,presignedUrl 是临时凭据:

UploadSession 响应示例
{
"fileId": "019c64e67fc87db8b7ca748f5442f91a",
"storageKey": "tenants/tenant-a/019c64e67fc87db8b7ca748f5442f91a",
"presignedUrl": "https://storage.example/presigned-put-token"
}

响应没有显式 expiresAt。S3 路径按处理器传入的 30 分钟签发;连接器路径忽略这个参数,使用 Provider 自己的 UploadUrlExpiration。

2. 哪些字段可信

字段谁提供处理器行为
TenantId当前用户空或 0 时返回 File.TenantRequired
OwnerId当前 UserId / ClientId两者都没有时返回 File.OwnerRequired
FileId服务端UUID v7,32 位 N 格式
StorageKey服务端tenants/{tenantId}/{fileId}
OwnerType客户端只验证非空和 50 字符上限
Visibility客户端只验证非空和 20 字符上限
FileName客户端不进入 Key,最大 255 字符
ContentType客户端暂存为后续比较基准
Size客户端只要求大于 0,没有平台上限

当前 OwnerId 表达“发起上传的用户或客户端”,不是订单、工单、会话等业务对象 ID。若需要业务归属,调用模块必须另建附件关系,不能把 OwnerType=document 当成已绑定文档。

3. 处理顺序

SysFileAssetIFileStorageFiles APIClientSysFileAssetIFileStorageFiles APIClientPOST upload-sessionderive tenant and ownerregister PendingUploadpresign PUT for 30 minutesURLSave FileAssetcommittedfileId + key + URLPUT bytes

注意 URL 在数据库 Save 之前签发。如果数据库写入失败,客户端仍可能持有可用的存储能力,而系统没有对应 FileAsset。生产方案需要补偿删除、短 TTL、Key 隔离和孤儿对象扫描共同兜底。

4. .NET 客户端完整上传

创建会话并直传 PDF
public async Task<string> UploadInvoiceAsync(
HttpClient api,
HttpClient objectStorage,
byte[] pdf,
CancellationToken cancellationToken)
{
// Files 只接收元数据。TenantId 与 OwnerId 来自 Bearer 身份。
using var createResponse = await api.PostAsJsonAsync(
"/api/files/upload-session",
new
{
ownerType = "user",
fileName = "invoice-2026-07.pdf",
contentType = "application/pdf",
size = pdf.LongLength,
visibility = "private"
},
cancellationToken);
createResponse.EnsureSuccessStatusCode();
var session = await createResponse.Content
.ReadFromJsonAsync<UploadSessionDto>(cancellationToken);
// 预签名 URL 自带对象存储授权;不要把 API Bearer Token 转发给存储服务。
using var put = new HttpRequestMessage(HttpMethod.Put, session!.PresignedUrl)
{
Content = new ByteArrayContent(pdf)
};
put.Content.Headers.ContentType = new("application/pdf");
using var uploadResponse = await objectStorage.SendAsync(put, cancellationToken);
uploadResponse.EnsureSuccessStatusCode();
// 这里只返回 FileId。业务绑定必须等 finalize 成功。
return session.FileId;
}
public sealed record UploadSessionDto(
string FileId,
string StorageKey,
string PresignedUrl);

不要持久化预签名 URL,也不要在日志中记录完整 URL:查询字符串通常包含签名、凭据范围和过期信息。

5. 浏览器端上传

浏览器直传并保留可重试状态
type UploadSession = {
fileId: string;
storageKey: string;
presignedUrl: string;
};
export async function createAndPut(file: File): Promise<UploadSession> {
// 会话请求只携带非可信元数据;认证上下文由 Cookie/Bearer 建立。
const sessionResponse = await fetch("/api/files/upload-session", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
ownerType: "user",
fileName: file.name,
contentType: file.type || "application/octet-stream",
size: file.size,
visibility: "private",
}),
});
if (!sessionResponse.ok)
throw new Error(`create session failed: ${sessionResponse.status}`);
const session = (await sessionResponse.json()) as UploadSession;
// PUT 只发送对象存储允许的头,不把应用认证信息转发给预签名地址。
const uploadResponse = await fetch(session.presignedUrl, {
method: "PUT",
headers: { "content-type": file.type || "application/octet-stream" },
body: file,
});
// PUT 失败时不要 finalize;重新创建会话比重用未知过期 URL 更安全。
if (!uploadResponse.ok)
throw new Error(`object upload failed: ${uploadResponse.status}`);
return session;
}

跨域直传需要对象存储 CORS 允许应用 Origin、PUT 和必要请求头。CORS 只是浏览器策略,不替代 Files 权限或 Bucket 私有策略。

6. 校验与错误

场景当前错误/行为客户端动作
Size ≤ 0File.InvalidSize修正输入,不重试
无可信 TenantFile.TenantRequired修正认证/租户解析
无 UserId/ClientIdFile.OwnerRequired使用受支持主体
元数据为空/过长File.MetadataRequired/TooLong修正输入
Provider 不支持 presignFile.UploadSessionUnavailable不要继续 PUT/finalize
数据库保存失败传播仓储错误不应重用已返回前的 URL;服务端需补偿
存储 PUT 超时对象状态未知HEAD/重建会话后再决定

命令没有实现 IIdempotentRequest,也没有接收 Idempotency-Key。网络超时后重复创建会产生新的 FileAsset 与 Key。

7. 文件名与内容类型

原始文件名只用于显示和下载建议名。服务端 Key 与文件名彻底分离,所以当前测试明确接受 ../file.pdf。这不意味着文件名安全:

  • UI 输出时做 HTML 编码;
  • Content-Disposition 删除 CR/LF/双引号,并考虑 RFC 5987 UTF-8;
  • 日志使用结构化字段并限制长度;
  • 不把扩展名当成 MIME 或内容可信证据;
  • 业务层为同名覆盖、保留字符和 Unicode 规范化制定策略。

当前 S3 下载适配器只删除三类 Header 注入字符;连接器适配器完全不传递 FileName。

8. 生产上传策略

建议在签发 URL 前增加集中策略端口:

上传准入策略的目标契约
public sealed record FileUploadIntent(
string TenantId,
string SubjectId,
string OwnerType,
string FileName,
string DeclaredContentType,
long DeclaredSize);
public interface IFileUploadAdmissionPolicy
{
// 返回批准后的规范 MIME、最大字节数与必须提供的 checksum 算法。
// 策略失败必须发生在签发 URL 之前,避免产生未登记的存储能力。
Task<Result<ApprovedUpload>> AuthorizeAsync(
FileUploadIntent intent,
CancellationToken cancellationToken);
}
public sealed record ApprovedUpload(
string ContentType,
long MaximumBytes,
string ChecksumAlgorithm);

这是目标扩展合同,当前源码没有该接口。它应组合租户套餐配额、OwnerType 注册表、MIME allowlist、风险控制和审计,而不是把规则散落在端点里。

9. 安全加固清单

  1. Bucket 保持私有,禁止通过猜测 Key 匿名读取。
  2. 预签名 PUT 约束 Content-Length、Content-Type 和 SHA-256。
  3. 对租户、主体、IP 和并发上传数限流。
  4. 在数据库上保存一次性会话标识与消费状态。
  5. finalize 强制检查 UploadExpiresAt。
  6. 服务端或可信异步处理器计算内容哈希。
  7. 上传进入 quarantine 前缀,扫描通过后再晋级可下载区。
  8. 会话创建失败后撤销/删除已签发目标。
  9. 监控已签发、上传、完成和清理的数量差。
  10. 不在日志、事件或前端状态库保留完整预签名 URL。

10. 验证命令

Terminal window
# 运行创建会话处理器测试,包含服务端 Key、非可信文件名和 fail-closed。
dotnet test tests/BitzOrcas.Application.Tests \
--filter FullyQualifiedName~CreateUploadSession
# 证明当前没有上传大小上限和会话过期拦截。
rg -n "Max.*File|Quota|UploadExpiresAt" src/Platform/Files src/Hosts -g '*.cs'
# 核对 S3 presign 是否加入长度、类型或 checksum 条件;当前只有 Bucket/Key/Verb/Expires。
rg -n "GetPreSignedUrlRequest|ContentLength|Checksum|ContentType" \
src/Framework/BitzOrcas.Infrastructure.Storage.S3Compatible -g '*.cs'

上一页:Files 概览 · 下一页:完成上传与完整性

100%

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