Files 把对象存储中的字节提升为可授权、可追踪、可被业务引用的 FileAsset。它已经实现服务端存储键、租户聚合、上传完成校验、Owner 下载策略、双 ORM 读写和 S3 预签名访问;但当前实现没有病毒扫描、配额、可信哈希计算、上传过期拦截和已删除对象回收,不能把它描述成完整的文件安全平台。
1. 模块解决什么问题
对象存储只知道 Bucket、Key 和字节,不知道租户、业务归属、授权策略或“能否绑定订单”。Files 在两者之间建立资产边界:
FileAsset保存租户、Owner、原始文件名、声明元数据、对象事实和生命周期;- Application 用例创建直传能力、完成上传、签发下载访问和删除资产;
IFileStorage隔离 Local、S3-compatible 与连接器 Provider;- 调用模块只保存
FileId,并继续拥有“附件属于哪个业务对象”的规则; FileAssetSummary作为只读事件/跨模块快照,不暴露可变聚合行为。
本模块不拥有业务附件表、图片裁剪、文档预览、内容识别、版本管理或合规保留决策。
2. 当前端到端路径
这里有两个独立事实源:数据库保存资产状态,对象存储保存字节。当前代码没有跨两者的原子事务,所以创建、完成、删除都要分析“数据库成功、对象失败”或反向组合。
3. HTTP 用例
| 方法与路由 | 消息 | 动作 | 当前结果 |
|---|---|---|---|
POST /api/files/upload-session | CreateUploadSessionCommand | Create | UploadSession,含 FileId、StorageKey、URL |
POST /api/files/{fileId}/finalize | FinalizeUploadCommand | Update | FileAssetSummary |
GET /api/files/{fileId}/download | GetFileDownloadAccessQuery | View | 五分钟语义的预签名下载描述 |
DELETE /api/files/{fileId} | DeleteFileCommand | Delete | 软删除资产并发布删除摘要 |
四个消息都实现 IAuthorizedRequest。治理目录声明的权限为 files.file.create/view/update/delete。下载处理器在通用 View 授权后,还会执行 FileAssetOwnerPolicy;删除处理器没有 Owner Policy,因此拥有 Delete 权限的调用者在有效租户内可删除任意文件。
4. 生命周期与绑定红线
CanBindToBusiness() 仅在 Finalized 返回 true。对象已经存在、数据库状态是 Uploaded、拥有 StorageKey 或拿到上传 URL,都不能替代这条领域判断。
FileAsset.Restore 是 ORM 物化工厂,不是“恢复已删除文件”的业务操作。当前没有 Restore 端点或 Deleted 到 Finalized 的状态迁移。
5. 信任边界
| 数据 | 来源 | 当前信任程度 |
|---|---|---|
| TenantId | ICurrentUser.User.TenantId | 服务端上下文,但未使用 EffectiveTenantId |
| OwnerId | 当前 UserId,否则 ClientId | 服务端派生 |
| OwnerType / Visibility | 客户端 | 只做非空与长度校验,没有注册表 |
| FileName | 客户端 | 不进入 StorageKey;仍会进入 DTO/响应头 |
| Size / ContentType | 创建命令 | finalize 时与对象元数据比较 |
| ContentHash | finalize 命令或对象 metadata | Provider 没有 SHA-256 时会回退信任客户端 |
| StorageKey | 服务端 UUID v7 | tenants/{tenantId}/{fileId} |
文件名即使是 ../file.pdf 也不会造成当前直传 Key 穿越,因为 Key 完全由服务端生成。它仍然属于非可信展示数据,下载响应、日志和 UI 必须分别转义。
6. 持久化模型
FileAsset 直接作为两个 ORM 的统一聚合模型写入 SysFileAsset:
- 表开启租户与软删除;
StorageKey有全局唯一索引;- Tenant + Owner、Tenant + Status 有普通索引;
StatusName写入数据库列Status,未知值在物化时失败关闭;- 非 Pending 状态必须有 ContentHash,Finalized 必须有 FinalizedAt;
- StorageKey 必须以
tenants/{tenantId}/或{tenantId}/开头。
字段长度在聚合工厂中提前验证:FileId 36、OwnerType 50、OwnerId 64、Visibility 20、StorageKey 512、ContentHash/ETag 128、ContentType 100、FileName 255。
7. 存储 Provider 现实矩阵
| Provider | 直接读写 | 预签名上传 | 元数据可信度 | 预签名下载 | 适用性 |
|---|---|---|---|---|---|
UnavailableFileStorage | 否 | 否 | 否 | 否 | Shell 失败关闭 |
LocalFileStorage | 是 | 否 | 长度可信;类型/哈希为空 | 否 | 端口测试或自定义直传,不支持当前 HTTP 闭环 |
S3CompatibleFileStore | 是 | 是 | Length/Type/ETag;SHA 取 user metadata | 是 | 当前生产路径 |
FileStoreAdapter | 取决于连接器 | 取决于连接器 | 只统一 Length | 取决于连接器 | 桥接面已实现,API 组合根当前未调用注册扩展 |
Production/Staging 配置门禁只接受 Minio,并要求 Endpoint、AccessKey、SecretKey。默认 appsettings.json 使用 Unavailable。
8. 当前最重要的实现缺口
- 没有最大文件大小、租户配额、MIME allowlist、Magic Bytes 或恶意内容扫描。
- 预签名 PUT 没有内容长度、Content-Type 或 checksum 条件。
UploadExpiresAt被保存,却从未在 finalize 中检查。- S3 缺少
x-amz-meta-sha256时,finalize 接受客户端 ContentHash;连接器路径还会接受客户端 ContentType。 - 重复 finalize 和 delete 都返回冲突,没有请求幂等键或结果重放。
- 创建时先签发 URL 后保存数据库;保存失败会留下仍可用的上传能力。
- finalize 保存和事件发布处于数据库事务路径,但对象已经在外部存储,回滚不能删除对象。
- Delete 只软删除数据库记录,不调用对象存储删除。
- 孤儿清理会立即选中所有 PendingUpload、忽略 UploadExpiresAt,也不处理 Deleted 文件。
- 下载 DTO 暴露 StorageKey;连接器忽略请求 TTL 和 FileName,返回的 ExpiresAt 可能与 URL 实际过期不一致。
9. 调用模块的正确责任
以 Ticket 附件为例,Ticket 模块应验证:
// 先通过 Files 的只读契约取得资产事实;不要直接查询 SysFileAsset。var asset = await fileAssets.GetSummaryAsync(command.FileId, cancellationToken);
// 业务 Owner 负责“这个文件能否成为当前工单附件”。if (asset.IsFailure || !asset.Value.Status.Equals(FileAssetStatus.Finalized)) return Result.Failure(TicketErrors.AttachmentNotReady);
if (asset.Value.TenantId != currentTenant.EffectiveTenantId) return Result.Failure(TicketErrors.AttachmentTenantMismatch);
// 业务表只保存稳定 FileId,不复制 URL、Bucket 或 StorageKey。ticket.Attach(command.FileId, command.DisplayName);这是基于当前公开契约的消费示例,不是源码逐字复制。真实调用还需要把业务 OwnerType/OwnerId 与附件归属规则闭环;当前创建接口把 OwnerId 固定为调用者身份,并不能登记任意业务对象 ID。
10. 阅读路径
关联基础章节:Authorization、Multitenancy、Auditing 和 GDPR。
11. 最小源码审查
# 列出四个公开用例、路由和授权动作。rg -n "GenerateEndpoint|AuthorizationAction" src/Platform/Files -g '*.cs'
# 查找真正的扫描、配额与过期拦截;当前预期没有生产命中。rg -n "Virus|Malware|Quota|UploadExpiresAt.*clock|MagicBytes" \ src/Platform/Files src/Hosts -g '*.cs'
# 核对删除是否触达对象存储;当前 DeleteFile 处理器没有 IFileStorage。rg -n "DeleteAsync|DeleteFileCommandHandler" \ src/Platform/Files src/Framework/BitzOrcas.Infrastructure.SqlSugar -g '*.cs'