Files 的数据生命周期横跨 SysFileAsset 与对象存储。配置一个能解析的 IFileStorage 只是起点;生产交付还必须证明 API 与 JobHost 使用同一 Provider、Bucket 与 Key 规则,并且创建失败、未完成上传、业务删除和保留期结束都能最终收敛。
1. IFileStorage 端口
平台端口只有七项能力:
public interface IFileStorage{ // 字节读写与删除是基础能力,调用者仍要处理不存在和重复删除语义。 Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken ct); Task<Stream> OpenReadAsync(string objectKey, CancellationToken ct); Task DeleteAsync(string objectKey, CancellationToken ct); // 预签名与元数据是 HTTP 直传闭环所需的可选 Provider 能力。 Task<string> GeneratePresignedUploadUrlAsync(string objectKey, TimeSpan ttl, CancellationToken ct); Task<bool> ExistsAsync(string objectKey, CancellationToken ct); Task<FileObjectMetadata> GetMetadataAsync(string objectKey, CancellationToken ct); Task<string> GeneratePresignedDownloadUrlAsync( string objectKey, string fileName, string contentType, TimeSpan ttl, CancellationToken ct);}Application 只依赖这个窄端口,不引用 AWS、MinIO 或连接器类型。Provider 不能完整实现某项能力时应抛 NotSupportedException,让用例失败关闭。
2. Provider 能力与限制
| 行为 | Unavailable | Local | S3-compatible | Connector bridge |
|---|---|---|---|---|
| Upload/Open/Delete | 抛不支持 | 支持 | 支持 | 委托 IFileStore |
| 路径防护 | 不适用 | GetFullPath 边界 | Bucket/Key | 取决于 Provider |
| Presigned PUT | 否 | 否 | 是 | 取决于 Provider |
| Metadata | 否 | Length/LastModified | HEAD facts | Exists + stream Length |
| Presigned GET | 否 | 否 | 是 | 取决于 Provider |
| 请求级 TTL | 否 | 否 | 支持 | 忽略 |
| 下载文件名 | 否 | 否 | Header override | 忽略 |
FileStoreAdapter.OpenReadAsync 在连接器返回 null 时改成 Stream.Null。这会把“不存在”伪装成零字节流,消费方若依赖 OpenRead 必须额外执行 Exists 或修改合同。
3. API 生产配置
当前 API 的生产门禁只接受 FileStorage:DefaultProvider=Minio:
{ "FileStorage": { "DefaultProvider": "Minio", "S3": { "Endpoint": "https://minio.internal.example", "AccessKey": "${FILE_STORAGE_ACCESS_KEY}", "SecretKey": "${FILE_STORAGE_SECRET_KEY}", "BucketName": "bitzorcas-files", "Region": "cn-east-1", "UseSsl": true, "ForcePathStyle": true, "CreateBucketIfNotExists": true } }}${...} 只表达由部署系统注入 Secret 的意图,不代表 .NET 配置自动展开该字符串。生产中用环境变量、User Secrets、Vault 或 AgileConfig Secret Reference 覆盖,禁止提交真实密钥。
容器级 FileStorage:Containers、Upload/Download TTL 和 MaxSinglePutBytes 已有 Options 字段,但当前四个 Files 用例没有读取这些配置;不能写成已生效策略。
4. API 组合顺序
S3 用普通 AddSingleton 注册,能够覆盖先前默认项。Local 使用 TryAddSingleton,而生成式 Unavailable 已经先注册;此外 API 没有为 Local 绑定 LocalFileStorageOptions。所以注释所说的“DefaultProvider=Local 自动启用开发存储”没有形成可证明的 API 运行时行为。
生产门禁拒绝 Local,降低了生产误用风险;开发环境仍应通过集成测试固定真实解析类型。
5. JobHost 配置必须独立验证
JobHost 的 AddJobHostFileStorage 路径更明确:
- Minio/S3 注册 S3-compatible;
- Local 调用
AddBitzOrcasStorage并设置 BaseDirectory; - 其他值注册 Unavailable。
API 和 JobHost 是两个进程。它们必须使用相同 Bucket、Endpoint、凭据权限和 Key 解释,否则清理作业可能看不到 API 写入的对象。
# Kubernetes 中分别查看两个 Deployment 的有效非敏感配置。kubectl -n bitzorcas get deploy bitzorcas-api bitzorcas-jobhost \ -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[0].env}{"\n"}{end}'
# 凭据只检查 Secret 引用名,不输出 Secret 值。kubectl -n bitzorcas get deploy bitzorcas-api bitzorcas-jobhost -o yaml \ | rg "secretKeyRef|FileStorage__DefaultProvider|FileStorage__S3__BucketName"6. S3 初始化与健康检查
AddBitzOrcasS3CompatibleStorage 注册 Client、Options、Store 和 BucketManager;API 还添加 s3-storage readiness check。检查项应证明:
- Endpoint 可达且 TLS 信任正确;
- 凭据能访问目标 Bucket;
- Bucket 存在或初始化权限符合策略;
- API 具有 PUT/HEAD/GET/DELETE 所需权限;
- JobHost 至少具有 HEAD/DELETE;
- 时钟偏差不会让签名立即失效。
健康检查绿色不等于 finalize 能取得 SHA-256,也不等于 Bucket Lifecycle 与清理作业正确。
7. 删除的当前语义
重复 Delete 通常无法通过普通软删除查询再次取得聚合;即便聚合层 Delete() 返回 File.AlreadyDeleted,HTTP 实际错误可能先表现为 NotFound。需要用真实仓储测试固定外部合同。
8. 孤儿清理的真实 SQL
JobHost 的 SqlSugarOrphanedFileCleanupAdapter 使用固定 24 小时阈值,但 SQL 实际是:
-- Pending 分支没有任何时间条件,因此作业启用后会立即选中全部待上传记录。-- Uploaded 分支才使用 24 小时 cutoff;Deleted 与 UploadExpiresAt 均未参与。SELECT [Id] AS FileId, [StorageKey], [Status] AS StatusNameFROM [SysFileAsset]WHERE ([Status] = 'PendingUpload') OR ([Status] = 'Uploaded' AND [FinalizedAt] IS NULL AND [CreateTime] < @cutoff);这意味着:
- 所有 PendingUpload 都会立即入选,没有 CreateTime 或 UploadExpiresAt 条件;
- Uploaded 只有超过 24 小时才入选;
- Deleted 永远不入选;
- UploadExpiresAt 完全没有使用;
- 查询与元数据物理删除没有传递 CancellationToken;
- 对象删除成功后才物理删除数据库行;对象失败则保留行重试。
作业默认关闭,默认 Cron 是每天 05:00。启用现状作业可能删除仍处于 30 分钟上传窗口中的对象,必须先修复选择条件。
9. 安全的目标清理模型
建议把不同原因拆成明确队列与状态:
| 原因 | 选择条件 | 保留/宽限 | 删除顺序 |
|---|---|---|---|
| 会话过期 | Pending + UploadExpiresAt < now | 短宽限防时钟偏差 | 对象 → 元数据 |
| 上传未完成 | Uploaded + 未 Finalized + age | 业务可配置 | 对象 → 元数据 |
| 业务删除 | Deleted + DeleteTime + retention | 法务/回收站策略 | 对象 → Tombstone/元数据 |
| 无元数据对象 | 存储 Inventory - DB Key | 更长安全窗口 | 标记 → 复核 → 删除 |
| 孤立元数据 | DB Key - 存储 Inventory | 立即阻止下载 | 证据 → 元数据修复 |
文件删除通常需要 Outbox/Saga:数据库先记录 DeleteRequested,后台幂等删除对象,再记录 Purged。直接让 HTTP 事务跨数据库与 S3 无法获得原子性。
10. 清理配置
{ "DataLifecycle": { "OrphanedFileCleanup": { "Enabled": true, "CronExpression": "0 0 5 * * ?" } }}当前阈值 24 小时硬编码在 OrphanedFileCleanupJobExecutor,Options 中没有阈值字段。修改 Cron 不会改变孤儿判定年龄。
11. Connector 桥接边界
AddBitzOrcasFileStorageConnectors 只识别 FileStorage:ConnectorProvider=Minio,但 API/JobHost 组合根当前没有调用这个扩展。文档不能据此承诺十个连接器 Provider 开箱可用。
即使显式接线,也要解决:
- 请求级 TTL 不可传;
- Metadata 没有 ContentType、ETag、SHA 或 LastModified;
- 下载 FileName 不可传;
- null stream 变成 Stream.Null;
- Provider 配置命名与
DefaultProvider双入口容易漂移。
每个 Provider 都必须通过相同契约测试,不能仅验证 DI 可解析。
12. 迁移与回滚
从 Local/S3-A 迁移到 S3-B 时,不要只切配置:
- 冻结或双写新的上传;
- 按 Tenant/Key Inventory 复制对象;
- 比较字节数与服务端计算哈希;
- 更新 Provider/Bucket 元数据或保留路由表;
- 抽样验证下载文件名和 ContentType;
- 切换读流量并监控 NotFound;
- 保留旧源到回滚窗口结束;
- 最后按批准的销毁计划清理旧对象。
当前 Handler 只使用全局 IFileStorage,没有按 FileAsset.StorageProvider/BucketName 路由历史资产的 Resolver。直接切全局 Provider 会让旧资产指向新 Bucket 中不存在的 Key。
13. 本地验证
# Local 端口测试只证明直接读写与路径穿越防护。dotnet test tests/BitzOrcas.Integration.Tests \ --filter FullyQualifiedName~LocalFileStorageTests
# 查看 API 与 JobHost 的不同 Provider 注册路径。rg -n "RegisterFileStorageProvider|AddJobHostFileStorage|AddBitzOrcasFileStorageConnectors" \ src/Hosts src/Platform/Files -g '*.cs'
# 暴露孤儿 SQL 与业务删除之间的空白;Deleted 应只命中聚合/处理器,不命中清理 SQL。rg -n "PendingUpload|Uploaded|Deleted|UploadExpiresAt" \ src/Framework/BitzOrcas.Infrastructure.SqlSugar/DataLifecycle \ src/Platform/Files -g '*.cs'