diff --git a/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs b/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs index dd3b7c140..f41bd13cd 100644 --- a/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs +++ b/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs @@ -3,6 +3,7 @@ using FellowOakDicom; using FellowOakDicom.Media; using IRaCIS.Core.Application.ViewModel; using IRaCIS.Core.Domain.Models; +using NPOI.Util; using System; using System.Collections.Generic; using System.Data; @@ -160,7 +161,7 @@ namespace IRaCIS.Core.Application.Helper } - public static async Task GenerateStudyDIR(List list, Dictionary dic,string dirSavePath) + public static async Task GenerateStudyDIR(List list, Dictionary dic, string? dirSavePath = null, Stream? outputStream = null) { var mappings = new List(); int index = 1; @@ -228,9 +229,18 @@ namespace IRaCIS.Core.Application.Helper //有实际的文件 if (mappings.Count > 0) - { + { // 保存 DICOMDIR 到临时文件 不能直接写入到流种 - await dicomDir.SaveAsync(dirSavePath); + + if (dirSavePath.IsNotNullOrEmpty()) + { + await dicomDir.SaveAsync(dirSavePath); + + } + else + { + await dicomDir.SaveAsync(outputStream); + } } } diff --git a/IRaCIS.Core.Application/Helper/OSSService.cs b/IRaCIS.Core.Application/Helper/OSSService.cs index 1114481db..57c28727c 100644 --- a/IRaCIS.Core.Application/Helper/OSSService.cs +++ b/IRaCIS.Core.Application/Helper/OSSService.cs @@ -948,7 +948,7 @@ public class OSSService(IOptionsMonitor options, lock (_tokenLock) { if (AliyunOSSTempToken == null || - AliyunOSSTempToken.Expiration <= DateTime.UtcNow.AddMinutes(15)) + AliyunOSSTempToken.Expiration <= DateTime.Now.AddMinutes(15)) { GetObjectStoreTempToken(); @@ -961,7 +961,7 @@ public class OSSService(IOptionsMonitor options, { - if (AWSTempToken != null && AWSTempToken.Expiration > DateTime.UtcNow.AddMinutes(15)) + if (AWSTempToken != null && AWSTempToken.Expiration > DateTime.Now.AddMinutes(15)) { return; } @@ -969,7 +969,7 @@ public class OSSService(IOptionsMonitor options, lock (_tokenLock) { if (AWSTempToken == null || - AWSTempToken.Expiration <= DateTime.UtcNow.AddMinutes(15)) + AWSTempToken.Expiration <= DateTime.Now.AddMinutes(15)) { GetObjectStoreTempToken(); diff --git a/IRaCIS.Core.Application/Service/Common/TrialImageDownloadService.cs b/IRaCIS.Core.Application/Service/Common/TrialImageDownloadService.cs index cc1d910ce..bba480b31 100644 --- a/IRaCIS.Core.Application/Service/Common/TrialImageDownloadService.cs +++ b/IRaCIS.Core.Application/Service/Common/TrialImageDownloadService.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; +using NPOI.Util; using Org.BouncyCastle.Utilities.Zlib; using SharpCompress.Common; using System; @@ -130,6 +131,358 @@ namespace IRaCIS.Core.Application.Service } + #region zip 流方式 直接下载 + + + public sealed class ZipItem + { + public string ZipEntryPath { get; set; } = ""; + + public string? OssPath { get; set; } + + public bool IsEncapsulated { get; set; } + + public Func? CustomWriter { get; set; } + } + + + private async Task CreateVisitZipAsync(string zipPath, List zipItems) + { + Directory.CreateDirectory( + Path.GetDirectoryName(zipPath)!); + + await using var zipFileStream = + new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4 * 1024 * 1024, useAsync: true); + + using var archive = new ZipArchive(zipFileStream, ZipArchiveMode.Create, leaveOpen: false); + + foreach (var item in zipItems) + { + var entry = archive.CreateEntry(item.ZipEntryPath.Replace("\\", "/"), CompressionLevel.NoCompression); + + await using var entryStream = entry.Open(); + + if (item.CustomWriter != null) + { + await item.CustomWriter(entryStream); + continue; + } + + if (string.IsNullOrWhiteSpace(item.OssPath)) + continue; + + if (item.IsEncapsulated) + { + var success = await TryWriteMergedDicomAsync(() => _oSSService.GetStreamFromOSSAsync(item.OssPath), entryStream); + + if (!success) + { + Log.Logger.Warning($"合并多帧失败:{item.ZipEntryPath}"); + } + + } + else + { + await using var ossStream = await _oSSService.GetStreamFromOSSAsync(item.OssPath); + + await ossStream.CopyToAsync(entryStream, 4 * 1024 * 1024); + } + + + } + } + + //查询一个访视 + // ↓ + //生成 DICOMDIR + // ↓ + //创建 ZipArchive + // ↓ + //OSS流直接写 ZipEntry + // ↓ + //完成一个访视.zip + // ↓ + //记录日志 + // ↓ + //处理下一个访视 + + [HttpPost] + [AllowAnonymous] + public async Task DownloadExcelTrialImageZIPStream(Guid trialId) + { + var trialInfo = _trialRepository.Where(t => t.Id == trialId).Select(t => new { t.ResearchProgramNo }).FirstOrDefault(); + + #region 设置目录 + + var rootFolder = FileStoreHelper.GetIRaCISRootDataFolder(_hostEnvironment); + Directory.CreateDirectory(rootFolder); + + // 获取无效字符(系统定义的) + string invalidChars = new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars()); + + // 用正则表达式替换所有非法字符为下划线或空字符 + string pattern = $"[{Regex.Escape(invalidChars)}]"; + + var regexNo = Regex.Replace(trialInfo.ResearchProgramNo, pattern, "_"); + + // 创建一个临时文件夹来存放文件 + string trialFolderPath = Path.Combine(rootFolder, $"{regexNo}");//_{NewId.NextGuid()} + Directory.CreateDirectory(trialFolderPath); + + #endregion + + var oldVisits = MiniExcel.Query(Path.Combine(rootFolder, "Old.xlsx")).ToList(); + + var downloadVisits = MiniExcel.Query(Path.Combine(rootFolder, "download.xlsx")).ToList().Where(t => t.SubjectCode.IsNotNullOrEmpty() && t.VisitName.IsNotNullOrEmpty()).ToList(); + + downloadVisits = downloadVisits.Where(t => !oldVisits.Any(old => old.VisitNum == t.VisitNum && old.SubjectCode == t.SubjectCode && + old.VisitName.Trim().ToLower() == t.VisitName.Trim().ToLower())).ToList(); + + var visitIndex = 0; + var skipCount = 0; + foreach (var downloadVisit in downloadVisits) + { + var downloadInfo = _trialRepository.Where(t => t.Id == trialId).Select(t => new + { + t.ResearchProgramNo, + t.TrialCode, + + VisitList = t.SubjectVisitList.Where(t => t.VisitName.Trim() == downloadVisit.VisitName.Trim() && t.Subject.Code.Trim() == downloadVisit.SubjectCode.Trim() && t.VisitNum == downloadVisit.VisitNum) + .Select(sv => new + { + SubjectVisitId = sv.Id, + TrialSiteCode = sv.TrialSite.TrialSiteCode, + SubjectCode = sv.Subject.Code, + VisitName = sv.VisitName, + VisitNum = sv.VisitNum, + StudyList = sv.StudyList.Select(u => new + { + StudyId = u.Id, + u.PatientId, + u.StudyTime, + u.StudyCode, + u.StudyInstanceUid, + u.StudyDIRPath, + + SeriesList = u.SeriesList.Where(t => t.IsReading).Select(z => new + { + z.Modality, + + InstancePathList = z.DicomInstanceList.Where(t => t.IsReading).Select(k => new + { + InstanceId = k.Id, + k.Path, + k.IsEncapsulated, + k.NumberOfFrames, + }).ToList() + }) + + }).ToList(), + + NoneDicomStudyList = sv.NoneDicomStudyList.Where(t => t.IsReading).Select(nd => new + { + nd.Modality, + nd.StudyCode, + nd.ImageDate, + + FileList = nd.NoneDicomFileList.Where(t => t.IsReading).Select(file => new + { + file.FileName, + file.Path, + file.FileType + }).ToList() + }).ToList() + }).OrderBy(t => t.SubjectCode).ThenBy(t => t.VisitNum).ToList() + + }).FirstOrDefault(); + + if (downloadInfo == null) + { + return ResponseOutput.Ok(); + } + + #region 排除已经下载的 + + var logFilePath = Path.Combine(rootFolder, $"{trialId}_{regexNo}_download_log.csv"); + + if (File.Exists(logFilePath)) + { + var existVisits = MiniExcel.Query(logFilePath, configuration: new MiniExcelLibs.Csv.CsvConfiguration() + { + StreamReaderFunc = (stream) => new StreamReader(stream, Encoding.GetEncoding("gb2312")) + }).ToList(); + + if (existVisits.Any(old => old.VisitNum == downloadVisit.VisitNum && old.SubjectCode == downloadVisit.SubjectCode && + old.VisitName.Trim().ToLower() == downloadVisit.VisitName.Trim().ToLower())) + { + Log.Logger.Warning($"[{visitIndex}] Excel显示已下载,跳过当前访视:{downloadVisit.SubjectCode} {downloadVisit.VisitName} {downloadVisit.VisitNum}"); + skipCount++; + continue; + } + + } + + + #endregion + + foreach (var visitItem in downloadInfo.VisitList) + { + if (visitItem.StudyList.Count() == 0 && visitItem.NoneDicomStudyList.Count() == 0) + { + Log.Logger.Warning($"[{visitIndex}]查询无检查,跳过当前访视:{downloadVisit.SubjectCode} {downloadVisit.VisitName} {downloadVisit.VisitNum}"); + skipCount++; + continue; + } + + try + { + #region 导出访视 + + var zipItems = new List(); + + var visitFolderName = $"{visitItem.SubjectCode}_{visitItem.VisitName.Trim()}"; + + foreach (var studyInfo in visitItem.StudyList) + { + var dirDic = new Dictionary(); + + var studyFolderName = $"{studyInfo.StudyCode}_{studyInfo.StudyTime:yyyy-MM-dd}_{string.Join('_', studyInfo.SeriesList.Select(t => t.Modality))}"; + + #region DICOMDIR + + + if (!_instanceRepository.Where(t => t.IsReading && t.DicomSerie.IsReading) + .Where(t => visitItem.SubjectVisitId == t.SubjectVisitId).Any(c => c.TransferSytaxUID == string.Empty)) + { + var list = _subjectVisitRepository.Where(t => t.Id == visitItem.SubjectVisitId).SelectMany(t => t.StudyList) + .SelectMany(t => t.InstanceList.Where(t => t.IsReading && t.DicomSerie.IsReading && t.StudyId == studyInfo.StudyId)) + .Select(t => new StudyDIRInfo() + { + + DicomStudyId = t.DicomStudy.Id, + + PatientId = downloadInfo.TrialCode + "-" + t.DicomStudy.Subject.Code, + PatientName = t.DicomStudy.PatientName, + PatientBirthDate = t.DicomStudy.PatientBirthDate, + PatientSex = t.DicomStudy.PatientSex, + + StudyInstanceUid = t.StudyInstanceUid, + StudyId = t.DicomStudy.StudyId, + DicomStudyDate = t.DicomStudy.DicomStudyDate, + DicomStudyTime = t.DicomStudy.DicomStudyTime, + AccessionNumber = t.DicomStudy.AccessionNumber, + + StudyDescription = t.DicomStudy.Description, + + SeriesInstanceUid = t.DicomSerie.SeriesInstanceUid, + Modality = t.DicomSerie.Modality, + DicomSeriesDate = t.DicomSerie.DicomSeriesDate, + DicomSeriesTime = t.DicomSerie.DicomSeriesTime, + SeriesNumber = t.DicomSerie.SeriesNumber, + SeriesDescription = t.DicomSerie.Description, + + InstanceId = t.Id, + SopInstanceUid = t.SopInstanceUid, + SOPClassUID = t.SOPClassUID, + InstanceNumber = t.InstanceNumber, + MediaStorageSOPClassUID = t.MediaStorageSOPClassUID, + MediaStorageSOPInstanceUID = t.MediaStorageSOPInstanceUID, + TransferSytaxUID = t.TransferSytaxUID, + + }).ToList(); + + foreach (var group in list.GroupBy(t => new { t.StudyInstanceUid, t.DicomStudyId })) + { + + + zipItems.Add(new ZipItem + { + ZipEntryPath = $"{visitFolderName}/{studyFolderName}/DICOMDIR", + + CustomWriter = async entryStream => + { + await DicomDIRHelper.GenerateStudyDIR(group.ToList(), dirDic, outputStream: entryStream); + } + }); + } + + } + + + #endregion + + + #region DICOM + + foreach (var seriesInfo in studyInfo.SeriesList) + { + foreach (var instanceInfo in seriesInfo.InstancePathList) + { + zipItems.Add(new ZipItem + { + OssPath = instanceInfo.Path, + + IsEncapsulated = instanceInfo.IsEncapsulated, + + ZipEntryPath = $"{visitFolderName}/" + $"{studyFolderName}/" + $"IMAGE/" + $"{dirDic[instanceInfo.InstanceId.ToString()]}" + }); + } + } + + #endregion + + + } + + #region NoneDicom + + foreach (var study in visitItem.NoneDicomStudyList) + { + var studyFolderName = $"{study.StudyCode}_" + $"{study.ImageDate:yyyy-MM-dd}_" + $"{study.Modality}"; + + foreach (var file in study.FileList) + { + zipItems.Add(new ZipItem + { + OssPath = HttpUtility.UrlDecode(file.Path), + + ZipEntryPath = $"{visitFolderName}/" + $"{studyFolderName}/" + $"{Path.GetFileName(file.FileName)}" + }); + } + } + + #endregion + + var zipPath = Path.Combine(rootFolder, visitFolderName + ".zip"); + + Log.Logger.Warning($"开始压缩访视:{visitFolderName}"); + + await CreateVisitZipAsync(zipPath, zipItems); + + DownloadLogger.Write(logFilePath, visitItem.SubjectCode, visitItem.VisitNum, visitItem.VisitName, "Success"); + + Log.Logger.Warning($"访视压缩完成:{visitFolderName}"); + + #endregion + + } + + catch (Exception ex) + { + Log.Logger.Error(ex, $"导出失败:{downloadVisit.SubjectCode} {downloadVisit.VisitName} {downloadVisit.VisitNum}"); + } + + + } + } + + return ResponseOutput.Ok(); + } + + #endregion + + + #region Excel 下载 先下载,然后再压缩,删除方式 public static class DownloadLogger { @@ -1000,6 +1353,8 @@ namespace IRaCIS.Core.Application.Service } + #endregion + /// /// 下载影像 维护dir信息 并回传到OSS