跳到主要内容
版本:3.1

文件管理(Aegis.FileManager)

本页是 Aegis 文件系统的概念入口。具体接入步骤见子页面:

组件概览

字段说明
组件名称文件管理
真实类库Aegis.FileManager
最新版本3.1.0
组件定位文件上传下载抽象层与共享模型基础层
引入方式安装 NuGet 后,配合具体存储实现一起使用
核心能力IFileManager 抽象、IFileStorage 容器管理抽象、共享模型
典型配套S3 对象存储NAS 本地存储

框架定位

Aegis 文件系统提供「存、取、删、列举」的统一抽象层,业务侧通过 IFileManager 接口操作文件,不关心底层是 S3、NAS 还是其他实现。

文件 Key(StorageKey)的拼装规则属于业务领域,由业务侧自行决定,框架原样使用业务传入的 Key——不做任何转换、不调用任何策略、不添加任何前缀。

核心概念:业务自拼 Key

业务方调用 IFileManager 时,必须传入完整的 StorageKey(即文件在存储后端中的唯一标识)。上传、读取、删除、列举等所有操作都用同一个 Key,行为完全对称。

为什么 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病历的业务归属月份(不是上传时刻跨月补传、跨时区都不会错位;便于按时间范围定位、审计、迁移
患者IDEMR 核心检索维度配合前缀 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);
}
}
业务表持久化 ObjectKey

业务侧通常在业务表(如 medical_recordlab_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

文件读写操作的统一抽象,所有方法都是异步的:

方法返回类型说明
DoesFileExistAsyncTask<bool>判断文件是否存在
UploadFileAsync (byte[])Task<FileUploadResult>字节数组上传
UploadFileAsync (Stream)Task<FileUploadResult>流上传
UploadFileAsync (filePath)Task<FileUploadResult>本地文件路径上传
GetFileStreamAsyncTask<Stream>获取文件流
GetFileBytesAsyncTask<byte[]>获取文件字节数组
GetFileAsyncTask<bool>下载文件到本地(支持目录或完整文件路径,见下方说明)
GetFileInfoAsyncTask<FileObject>获取文件元数据
GetFileUriAsyncTask<string>获取文件访问路径(NAS 本地路径 / S3 Presigned URL)
DeleteFileAsyncTask<bool>删除文件
ListFilesAsyncTask<FileListResult>列举文件,支持前缀过滤和分页
CopyFileAsyncTask<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 是目录还是文件路径(命中即停):

  1. filePath 已存在且是目录 → 视为目录
  2. filePath/\ 结尾 → 视为目录
  3. filePath 不含文件扩展名 → 视为目录
  4. filePath 含扩展名 → 视为完整文件路径

易踩坑:传 filePath = "C:\tmp" 会被识别为目录(tmp 无扩展名)。如果想落地为名为 tmp 的无扩展名文件,请直接传完整路径或加扩展名。

fileName 是含路径分隔符的 ObjectKey(如 37/202509/P0086235/20250915_OPV000312.pdf),框架只取最后一段作为落地文件名(20250915_OPV000312.pdf),不会创建多层子目录。

IFileStorage

存储容器管理的抽象:

方法返回类型说明
GroupExistsAsyncTask<bool>检查存储组是否存在
CreateGroupAsyncTask<bool>创建存储组(幂等,已存在返回 true)
DeleteGroupAsyncTask<bool>删除存储组(必须为空)
ListGroupsAsyncTask<List<StorageGroupInfo>>列举所有存储组
GetGroupInfoAsyncTask<StorageGroupInfo>获取存储组详情

核心模型

FileUploadResult

UploadFileAsync 返回的上传结果:

属性类型说明
Successbool上传是否成功
ObjectKeystring实际写入的存储 Key(S3 ObjectKey / NAS 文件名),等于业务传入的 fileName
FileNamestring业务传入的原始文件名
GroupNamestring存储组(S3 Bucket / NAS 子目录)
保存 ObjectKey

上传后务必将 ObjectKey 保存到数据库,后续的读取、删除、获取 URL 等操作都使用 ObjectKey 作为文件标识。

FileObject

GetFileInfoAsync 返回的文件元数据:

属性类型说明
FileNamestring文件名
FileSizelong文件大小(字节)
ContentMd5string?文件 MD5(S3 为 ETag,NAS 按需计算)
ContentTypestring?MIME 类型(从扩展名自动推断)
TypeFileType文件类型枚举
LastModifiedDateTimeOffset最后更新时间
FileUristring?文件访问路径
MetadataDictionary<string, string>自定义元数据

FileListResult

ListFilesAsync 返回的列举结果:

属性类型说明
FilesList<FileObject>当前页的文件列表
IsTruncatedbool是否还有更多文件可翻页
NextContinuationTokenstring?下一页分页 token
CurrentPageCountint当前页文件数量

StorageGroupInfo

IFileStorage 返回的存储组信息:

属性类型说明
Namestring存储组名称
CreatedDateDateTimeOffset创建时间
Regionstring?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,请直接注入具体的实现类(如 S3FileManagerNasFileManager),而非 IFileManager