CreateUploadSession 的任务不是接收文件字节,而是登记一条 PendingUpload 资产,并向已授权调用者签发一个临时 PUT 能力。客户端随后直接把字节发送到对象存储,避免 API 进程代理大文件流量。
1. 请求与响应合同
POST /api/files/upload-session HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "ownerType": "user", "fileName": "invoice-2026-07.pdf", "contentType": "application/pdf", "size": 48231, "visibility": "private"}成功响应中的 fileId 与 storageKey 由服务端生成,presignedUrl 是临时凭据:
{ "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. 处理顺序
注意 URL 在数据库 Save 之前签发。如果数据库写入失败,客户端仍可能持有可用的存储能力,而系统没有对应 FileAsset。生产方案需要补偿删除、短 TTL、Key 隔离和孤儿对象扫描共同兜底。
4. .NET 客户端完整上传
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 ≤ 0 | File.InvalidSize | 修正输入,不重试 |
| 无可信 Tenant | File.TenantRequired | 修正认证/租户解析 |
| 无 UserId/ClientId | File.OwnerRequired | 使用受支持主体 |
| 元数据为空/过长 | File.MetadataRequired/TooLong | 修正输入 |
| Provider 不支持 presign | File.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. 安全加固清单
- Bucket 保持私有,禁止通过猜测 Key 匿名读取。
- 预签名 PUT 约束 Content-Length、Content-Type 和 SHA-256。
- 对租户、主体、IP 和并发上传数限流。
- 在数据库上保存一次性会话标识与消费状态。
- finalize 强制检查 UploadExpiresAt。
- 服务端或可信异步处理器计算内容哈希。
- 上传进入 quarantine 前缀,扫描通过后再晋级可下载区。
- 会话创建失败后撤销/删除已签发目标。
- 监控已签发、上传、完成和清理的数量差。
- 不在日志、事件或前端状态库保留完整预签名 URL。
10. 验证命令
# 运行创建会话处理器测试,包含服务端 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'