文件管理(Aegis.FileManager)
本页是 Aegis 文件系统的概念入口。具体接入步骤见子页面:
组件概览
| 字段 | 说明 |
|---|---|
| 组件名称 | 文件管理 |
| 真实类库 | Aegis.FileManager |
| 最新版本 | 3.1.0 |
| 组件定位 | 文件上传下载抽象层与共享模型基础层 |
| 引入方式 | 安装 NuGet 后,配合具体存储实现一起使用 |
| 核心能力 | IFileManager 抽象、IFileStorage 容器管理抽象、共享模型 |
| 典型配套 | S3 对象存储、NAS 本地存储 |
框架定位
Aegis 文件系统提供「存、取、删、列举」的统一抽象层,业务侧通过 IFileManager 接口操作文件,不关心底层是 S3、NAS 还是其他实现。
文件 Key(StorageKey)的拼装规则属于业务领域,由业务侧自行决定,框架原样使用业务传入的 Key——不做任何转换、不调用任何策略、不添加任何前缀。
核心概念:业务自拼 Key
业务方调用 IFileManager 时,必须传入完整的 StorageKey(即文件在存储后端中的唯一标识)。上传、读取、删除、列举等所有操作都用同一个 Key,行为完全对称。
StorageKey 的生成规则属于业务领域知识:不同业务场景需要不同的维度组合(患者ID、就诊日期、文书类型等),框架无法预知。把 Key 拼装放在业务侧的静态工具类里,业务维度作为方法参数显式声明,调用契约清晰、可单元测试、可复用。
医疗场景的标准 Key 范式
Aegis 文件系统主要服务医疗行业。推荐的 EMR 病历 Key 范式:
{2位哈希前缀}/{数据年月YYYYMM}/{患者ID}/{单据日期YYYYMMDD}_{单据编号}.{后缀}
示例:37/202509/P0086235/20250915_OPV000312.pdf
| 段 | 含义 | 设计理由 |
|---|---|---|
| 2位哈希 | 患者ID 的 MD5 前 2 位 hex(100 个桶) | 写入防热点;同一患者永远落在同一前缀,便于按患者精准 ListObjects |
| 数据年月 YYYYMM | 病历的业务归属月份(不是上传时刻) | 跨月补传、跨时区都不会错位;便于按时间范围定位、审计、迁移 |
| 患者ID | EMR 核心检索维度 | 配合前缀 ListObjectsV2 --prefix {hash}/{yyyymm}/{patientId}/ 一条命令找齐该患者当月所有病历 |
| 单据日期_单据编号.后缀 | 区分同患者同日多次就诊 | 编号隐含文书类型:OPV=门诊、LAB=检验、DSC=出院小结、CON=知情同意书、SUR=手术、SIG=签名 |
业务侧工具类示例
using System.Security.Cryptography;
using System.Text;
public static class EmrKeyBuilder
{
/// <summary>
/// 生成 EMR 病历文件的 StorageKey
/// 格式:{2位hash}/{yyyymm}/{patientId}/{yyyymmdd}_{docNumber}.{ext}
/// </summary>
public static string Build(
string patientId,
DateOnly businessMonth,
DateOnly documentDate,
string documentNumber,
string fileName)
{
// 哈希源是患者ID(稳定标识),不是文件名
var hash = MD5.HashData(Encoding.UTF8.GetBytes(patientId));
var prefix = Convert.ToHexString(hash).ToLowerInvariant()[..2];
var monthSegment = businessMonth.ToString("yyyyMM");
var docSegment = $"{documentDate:yyyyMMdd}_{documentNumber}";
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return $"{prefix}/{monthSegment}/{patientId}/{docSegment}{ext}";
}
/// <summary>
/// 生成患者某月所有病历的 ListObjects 前缀
/// </summary>
public static string PrefixForPatientMonth(string patientId, DateOnly businessMonth)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes(patientId));
var prefix = Convert.ToHexString(hash).ToLowerInvariant()[..2];
return $"{prefix}/{businessMonth:yyyyMM}/{patientId}/";
}
/// <summary>
/// 生成患者全部病历的 ListObjects 前缀(跨所有月份)
/// </summary>
public static string PrefixForPatient(string patientId)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes(patientId));
var prefix = Convert.ToHexString(hash).ToLowerInvariant()[..2];
return $"{prefix}/{patientId}/";
}
}
S3 ObjectKey 对字符有严格约束,框架不做任何规范化。业务侧在工具类里必须保证:
- 仅使用数字、字母、
/、-、_、.、(、) - 禁止中文、空格、特殊符号(
& @ : , ? + $ \ # { } [ ] ~ ^ % " < > |) - 后缀统一小写,避免大小写差异产生不同对象
- 病历名、科室名等中文信息映射到 S3 对象元数据,而非 Key 的一部分
业务侧使用模式
public class EmrService
{
private readonly IFileManager _fileManager;
public EmrService(IFileManager fileManager) => _fileManager = fileManager;
// 上传:业务拼好 Key 传入,框架原样使用
public async Task<string> UploadRecord(
string patientId, DateOnly visitDate,
string documentNumber, Stream content, string originalFileName)
{
var key = EmrKeyBuilder.Build(
patientId: patientId,
businessMonth: new DateOnly(visitDate.Year, visitDate.Month, 1),
documentDate: visitDate,
documentNumber: documentNumber,
fileName: originalFileName);
// key = "37/202509/P0086235/20250915_OPV000312.pdf"
var result = await _fileManager.UploadFileAsync(key, content, "emr-records");
return result.ObjectKey;
}
// 读取:用同样的工具方法拼回 Key
public Task<Stream> DownloadRecord(
string patientId, DateOnly visitDate, string documentNumber, string fileName)
{
var key = EmrKeyBuilder.Build(
patientId, new DateOnly(visitDate.Year, visitDate.Month, 1),
visitDate, documentNumber, fileName);
return _fileManager.GetFileStreamAsync(key, "emr-records");
}
// 列举某患者当月所有病历:用 Prefix 辅助方法
public Task<FileListResult> ListPatientMonthFiles(string patientId, DateOnly month)
{
var prefix = EmrKeyBuilder.PrefixForPatientMonth(patientId, month);
// prefix = "37/202509/P0086235/"
return _fileManager.ListFilesAsync("emr-records", prefix: prefix);
}
}
业务侧通常在业务表(如 medical_record、lab_report)中增加 s3_key 字段,写入业务数据时同步写入拼好的 Key:
BEGIN;
INSERT INTO medical_record (patient_id, visit_id, doc_category, s3_key, ...)
VALUES ('P0086235', 'V202509001', 'OPV', '37/202509/P0086235/20250915_OPV000312.pdf', ...);
-- S3 PUT 上传文件(失败则回滚事务)
COMMIT;
业务数据查询直接关联到 S3 Key,无需额外搭建索引库。
核心接口
IFileManager
文件读写操作的统一抽象,所有方法都是异步的:
| 方法 | 返回类型 | 说明 |
|---|---|---|
DoesFileExistAsync | Task<bool> | 判断文件是否存在 |
UploadFileAsync (byte[]) | Task<FileUploadResult> | 字节数组上传 |
UploadFileAsync (Stream) | Task<FileUploadResult> | 流上传 |
UploadFileAsync (filePath) | Task<FileUploadResult> | 本地文件路径上传 |
GetFileStreamAsync | Task<Stream> | 获取文件流 |
GetFileBytesAsync | Task<byte[]> | 获取文件字节数组 |
GetFileAsync | Task<bool> | 下载文件到本地(支持目录或完整文件路径,见下方说明) |
GetFileInfoAsync | Task<FileObject> | 获取文件元数据 |
GetFileUriAsync | Task<string> | 获取文件访问路径(NAS 本地路径 / S3 Presigned URL) |
DeleteFileAsync | Task<bool> | 删除文件 |
ListFilesAsync | Task<FileListResult> | 列举文件,支持前缀过滤和分页 |
CopyFileAsync | Task<bool> | 复制文件,支持跨组 |
所有方法的 fileName 参数即为 StorageKey,框架原样使用。groupName 参数默认值为空字符串,具体含义由存储实现决定(NAS 为子目录,S3 为 Bucket)。
GetFileAsync 的 filePath 参数约定
GetFileAsync(fileName, filePath, groupName) 把文件下载到本地磁盘,filePath 参数同时支持两种传法:
// 方式 1:传完整文件路径(推荐,最不易误用)
await fm.GetFileAsync("report.pdf", @"C:\tmp\report.pdf", "attachments");
// 落地路径:C:\tmp\report.pdf
// 方式 2:只传目录,框架用 fileName 自动拼文件名
await fm.GetFileAsync("report.pdf", @"C:\tmp", "attachments");
// 落地路径:C:\tmp\report.pdf(自动拼)
框架按以下顺序判断 filePath 是目录还是文件路径(命中即停):
filePath已存在且是目录 → 视为目录filePath以/或\结尾 → 视为目录filePath不含文件扩展名 → 视为目录filePath含扩展名 → 视为完整文件路径
易踩坑:传 filePath = "C:\tmp" 会被识别为目录(tmp 无扩展名)。如果想落地为名为 tmp 的无扩展名文件,请直接传完整路径或加扩展名。
当 fileName 是含路径分隔符的 ObjectKey(如 37/202509/P0086235/20250915_OPV000312.pdf),框架只取最后一段作为落地文件名(20250915_OPV000312.pdf),不会创建多层子目录。
IFileStorage
存储容器管理的抽象:
| 方法 | 返回类型 | 说明 |
|---|---|---|
GroupExistsAsync | Task<bool> | 检查存储组是否存在 |
CreateGroupAsync | Task<bool> | 创建存储组(幂等,已存在返回 true) |
DeleteGroupAsync | Task<bool> | 删除存储组(必须为空) |
ListGroupsAsync | Task<List<StorageGroupInfo>> | 列举所有存储组 |
GetGroupInfoAsync | Task<StorageGroupInfo> | 获取存储组详情 |
核心模型
FileUploadResult
UploadFileAsync 返回的上传结果:
| 属性 | 类型 | 说明 |
|---|---|---|
Success | bool | 上传是否成功 |
ObjectKey | string | 实际写入的存储 Key(S3 ObjectKey / NAS 文件名),等于业务传入的 fileName |
FileName | string | 业务传入的原始文件名 |
GroupName | string | 存储组(S3 Bucket / NAS 子目录) |
上传后务必将 ObjectKey 保存到数据库,后续的读取、删除、获取 URL 等操作都使用 ObjectKey 作为文件标识。
FileObject
GetFileInfoAsync 返回的文件元数据:
| 属性 | 类型 | 说明 |
|---|---|---|
FileName | string | 文件名 |
FileSize | long | 文件大小(字节) |
ContentMd5 | string? | 文件 MD5(S3 为 ETag,NAS 按需计算) |
ContentType | string? | MIME 类型(从扩展名自动推断) |
Type | FileType | 文件类型枚举 |
LastModified | DateTimeOffset | 最后更新时间 |
FileUri | string? | 文件访问路径 |
Metadata | Dictionary<string, string> | 自定义元数据 |
FileListResult
ListFilesAsync 返回的列举结果:
| 属性 | 类型 | 说明 |
|---|---|---|
Files | List<FileObject> | 当前页的文件列表 |
IsTruncated | bool | 是否还有更多文件可翻页 |
NextContinuationToken | string? | 下一页分页 token |
CurrentPageCount | int | 当前页文件数量 |
StorageGroupInfo
IFileStorage 返回的存储组信息:
| 属性 | 类型 | 说明 |
|---|---|---|
Name | string | 存储组名称 |
CreatedDate | DateTimeOffset | 创建时间 |
Region | string? | S3 Region,NAS 实现为空 |
怎么选存储后端
| 场景 | 推荐 | 链接 |
|---|---|---|
| 多节点部署、需要共享文件、希望客户端直连下载 | S3 对象存储(MinIO / 兼容服务) | S3 文档 |
| 单机部署或已有 NAS 共享挂载、追求简单 | NAS 本地存储 | NAS 文档 |
自定义存储后端
如果内置的 NAS 和 S3 实现都不满足需求,可以实现 IFileManager 接口来自定义存储后端:
public class OssFileManager : IFileManager
{
// 实现 IFileManager 的所有方法...
}
// 注册
services.AddSingleton<IFileManager, OssFileManager>();
常见问题
只安装了 Aegis.FileManager,注入 IFileManager 报错
这是因为你只引入了抽象层,还没有注册具体存储实现。需要再接入一个实际的文件管理器,例如 NAS 本地存储 或 S3 对象存储。
多个存储后端如何共存
IFileManager 只能绑定一个默认实现。如果项目同时使用 NAS 和 S3,请直接注入具体的实现类(如 S3FileManager、NasFileManager),而非 IFileManager。