diff --git a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs index 259aaaea9..c31d31923 100644 --- a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs +++ b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs @@ -944,12 +944,12 @@ namespace IRaCIS.Core.API.Controllers var progress = new { CountPercent = totalCount > 0 - ? Math.Round(receivedCount * 100m / totalCount, 2).ToString() + "%" - : "0%", + ? Math.Round(receivedCount * 100m / totalCount, 2).ToString() + : "0", SizePercent = totalSize > 0 - ? Math.Round(receivedSize * 100m / totalSize, 2).ToString() + "%" - : "0%", + ? Math.Round(receivedSize * 100m / totalSize, 2).ToString() + : "0", Speed = (speedBps / 1024 >= 1024 ? (speedBps / 1024 / 1024).ToString("0.00") + " MB/s" @@ -1077,7 +1077,190 @@ namespace IRaCIS.Core.API.Controllers + [HttpPost("download/VisitImageDownload")] + public async Task VisitImageDownload([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService, + [FromServices] IHubContext _downLoadHub, + [FromServices] IRepository _trialRepository, + VisitImageDownloadCommand inCommand) + { + var rusult = await _patientService.GetDownloadSubjectVisitStudyInfo(inCommand); + var visitList = rusult.Data; + var downloadInfo = (SubejctVisitDownload)rusult.OtherData; + + var trialInfo = _trialRepository.Where(t => t.Id == inCommand.TrialId).Select(t => new { t.TrialCode, t.ResearchProgramNo }).FirstOrDefault(); + + var trialZipName = $"{trialInfo.TrialCode}_{trialInfo.ResearchProgramNo}_Image_"; + + long receivedSize = 0; + long receivedCount = 0; + long totalSize = downloadInfo.ImageSize; + long totalCount = downloadInfo.ImageCount; + + var abortToken = HttpContext.RequestAborted; + + // -------- SignalR 节流参数 -------- + var notifyInterval = TimeSpan.FromSeconds(1); + var lastNotify = DateTime.UtcNow; + + // 用于计算下载速度 + long lastReceivedSize = 0; + DateTime lastSpeedCheck = DateTime.UtcNow; + + async Task NotifyProgressAsync(bool force = false) + { + + var now = DateTime.UtcNow; + var elapsedSeconds = (now - lastSpeedCheck).TotalSeconds; + + // 如果没有强制推送,并且未到推送间隔,则返回 + if (!force && elapsedSeconds < notifyInterval.TotalSeconds) + return; + + // 计算下载速度(字节/秒) + double speedBps = 0; + if (elapsedSeconds > 0) + { + speedBps = (receivedSize - lastReceivedSize) / elapsedSeconds; + } + + lastSpeedCheck = now; + lastReceivedSize = receivedSize; + lastNotify = DateTime.UtcNow; + + var progress = new + { + CountPercent = totalCount > 0 + ? Math.Round(receivedCount * 100m / totalCount, 2).ToString() + : "0", + + SizePercent = totalSize > 0 + ? Math.Round(receivedSize * 100m / totalSize, 2).ToString() + : "0", + + Speed = (speedBps / 1024 >= 1024 + ? (speedBps / 1024 / 1024).ToString("0.00") + " MB/s" + : (speedBps / 1024).ToString("0.00") + " KB/s") + }; + + // 不阻塞下载流程 + _ = _downLoadHub.Clients + .User(_userInfo.IdentityUserId.ToString()) + .ReceivProgressAsync( + inCommand.CurrentNoticeId, + progress + ); + } + + Response.ContentType = "application/zip"; + Response.Headers["Content-Disposition"] = $"attachment; filename={trialZipName}{ExportExcelConverterDate.DateTimeInternationalToString(DateTime.Now, _userInfo.TimeZoneId)}.zip"; + Response.Headers["Cache-Control"] = "no-store"; + + + try + { + await using var responseStream = Response.BodyWriter.AsStream(); + using var zip = new ZipArchive(responseStream, ZipArchiveMode.Create, leaveOpen: true); + + foreach (var visit in visitList) + { + foreach (var study in visit.StudyList) + { + abortToken.ThrowIfCancellationRequested(); + + var studyTime = study.StudyTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "UnknownTime"; + var modalitysStr = string.Join('_', study.SeriesList.Select(t => t.Modality).Distinct()); + + // ---------- DICOMDIR ---------- + var dicomDirPath = $"{visit.SubjectCode}_{visit.VisitName}/{studyTime}_{modalitysStr}/DICOMDIR"; + var dicomDirEntry = zip.CreateEntry(dicomDirPath, CompressionLevel.Fastest); + + await using (var entryStream = dicomDirEntry.Open()) + await using (var dirStream = await _oSSService.GetStreamFromOSSAsync(study.StudyDIRPath)) + { + await dirStream.CopyToAsync(entryStream, 32 * 1024, abortToken); + } + + // ---------- IMAGE FILES ---------- + foreach (var series in study.SeriesList) + { + foreach (var instance in series.InstanceList) + { + //当前完成大小 + receivedSize = receivedSize + instance.FileSize ?? 0; + receivedCount++; + + abortToken.ThrowIfCancellationRequested(); + + var entryPath = + $"{visit.SubjectCode}_{visit.VisitName}/{studyTime}_{modalitysStr}/IMAGE/{instance.FileName}"; + + var entry = zip.CreateEntry(entryPath, CompressionLevel.Fastest); + + await using var entryStream = entry.Open(); + await using var source = await _oSSService.GetStreamFromOSSAsync(instance.Path); + + #region 将多帧合并为一帧 + + // 如果你是从 stream 打开 + var dicomFile = await DicomFile.OpenAsync(source); + + // 获取 Pixel Data 标签 + var pixelData = DicomPixelData.Create(dicomFile.Dataset); + //获取像素是否为封装形式 + var syntax = dicomFile.Dataset.InternalTransferSyntax; + + //对于封装像素的文件做转换 + if (syntax.IsEncapsulated) + { + // 创建一个新的片段序列 + var newFragments = new DicomOtherByteFragment(DicomTag.PixelData); + // 获取每帧数据并封装为单独的片段 + for (int n = 0; n < pixelData.NumberOfFrames; n++) + { + var frameData = pixelData.GetFrame(n); + newFragments.Fragments.Add(new MemoryByteBuffer(frameData.Data)); + } + // 替换原有的片段序列 + dicomFile.Dataset.AddOrUpdate(newFragments); + } + + #endregion + + await dicomFile.SaveAsync(entryStream); + //await source.CopyToAsync(entryStream, 32 * 1024, abortToken); + + + await NotifyProgressAsync(); + } + } + } + } + + // 正常完成 + await NotifyProgressAsync(true); + await _patientService.DownloadImageSuccess(downloadInfo.Id); + } + catch (OperationCanceledException) + { + // ✅ 客户端取消 / 断开 —— 正常情况 + Log.Logger.Warning("Download canceled by client"); + } + catch (IOException ex) when (abortToken.IsCancellationRequested) + { + // ✅ HttpClient 流在中断时的常见异常 + Log.Logger.Warning($"Client disconnected: {ex.Message}"); + } + catch (NullReferenceException ex) when (abortToken.IsCancellationRequested) + { + // ✅ HttpConnection.ContentLengthReadStream 已知问题 + Log.Logger.Warning($"Stream aborted: {ex.Message}"); + } + + return new EmptyResult(); + + + } } diff --git a/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs b/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs index d2502796a..8eb557488 100644 --- a/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs +++ b/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs @@ -13,6 +13,8 @@ namespace IRaCIS.Core.Application.Helper public class StudyDIRInfo { + public Guid SubjectId { get; set; } + public Guid TrialId { get; set; } public Guid SubjectVisitId { get; set; } // Study public Guid DicomStudyId { get; set; } diff --git a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml index 5c717c215..24814e0e3 100644 --- a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml +++ b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml @@ -18834,12 +18834,10 @@ - + 获取下载的访视检查信息 - - diff --git a/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs b/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs index f6eca470d..e373533be 100644 --- a/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs +++ b/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs @@ -965,6 +965,16 @@ namespace IRaCIS.Application.Contracts } + public class VisitImageDownloadCommand + { + public Guid TrialId { get; set; } + + public List SubjectVisitIdList { get; set; } + + [NotDefault] + public string CurrentNoticeId { get; set; } + } + public class PatientImageDownloadCommand { public List PatientIdList { get; set; } @@ -1211,6 +1221,19 @@ namespace IRaCIS.Application.Contracts } + public class DownloadVisitDto + { + public Guid TrialId { get; set; } + + public Guid SubjectId { get; set; } + + public string SubjectCode { get; set; } + + public string VisitName { get; set; } + + public List StudyList { get; set; } + } + public class DownloadPatientDto { public string PatientName { get; set; } diff --git a/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs b/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs index d12902fb1..892d76b43 100644 --- a/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs +++ b/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs @@ -10,5 +10,8 @@ namespace IRaCIS.Application.Interfaces public Task DownloadImageSuccess(Guid trialImageDownloadId); + + public Task>> GetDownloadSubjectVisitStudyInfo(VisitImageDownloadCommand inCommand); + } } diff --git a/IRaCIS.Core.Application/Service/Visit/PatientService.cs b/IRaCIS.Core.Application/Service/Visit/PatientService.cs index c897ca6a8..a27578711 100644 --- a/IRaCIS.Core.Application/Service/Visit/PatientService.cs +++ b/IRaCIS.Core.Application/Service/Visit/PatientService.cs @@ -55,6 +55,7 @@ namespace IRaCIS.Application.Services public class PatientService(IRepository _studySubjectVisitRepository, IRepository _trialIdentityUserRepository, IRepository _SCPStudyRepository, IOSSService _oSSService, + IRepository _dicomStudyRepository, IRepository _identityUserRepository, IRepository _subjectPatientRepository, IRepository _SCPStudyHospitalGroupRepository, IRepository _trialRepository, @@ -3458,11 +3459,8 @@ namespace IRaCIS.Application.Services /// /// 获取下载的访视检查信息 /// - /// - /// /// - public async Task GetDownloadSubjectVisitStudyInfo(Guid trialId, Guid subjectVisitId, - [FromServices] IRepository _dicomStudyRepository, [FromServices] IOSSService _oSSService) + public async Task >> GetDownloadSubjectVisitStudyInfo(VisitImageDownloadCommand inCommand) { var dirDic = new Dictionary(); @@ -3470,13 +3468,15 @@ namespace IRaCIS.Application.Services #region DIR处理导出文件名,并将对应关系上传到OSS里面存储 //有传输语法值的导出 才生成DIR - if (_subjectVisitRepository.Where(t => t.Id == subjectVisitId).SelectMany(t => t.StudyList.SelectMany(t => t.InstanceList)).All(c => c.TransferSyntaxUID != string.Empty)) + if (_subjectVisitRepository.Where(t => inCommand.SubjectVisitIdList.Contains(t.Id) ).SelectMany(t => t.StudyList.SelectMany(t => t.InstanceList)).All(c => c.TransferSyntaxUID != string.Empty)) { - var list = _subjectVisitRepository.Where(t => t.Id == subjectVisitId).SelectMany(t => t.StudyList) + var list = _subjectVisitRepository.Where(t => inCommand.SubjectVisitIdList.Contains(t.Id)).SelectMany(t => t.StudyList) .SelectMany(t => t.InstanceList) .Select(t => new StudyDIRInfo() { - + SubjectId=t.SubjectId, + TrialId=t.TrialId, + SubjectVisitId=t.SubjectVisitId, DicomStudyId = t.DicomStudy.Id, PatientId = t.DicomStudy.PatientId, @@ -3509,11 +3509,11 @@ namespace IRaCIS.Application.Services }).ToList(); - var pathInfo = await _subjectVisitRepository.Where(t => t.Id == subjectVisitId).Select(t => new { t.TrialId, t.SubjectId, VisitId = t.Id }).FirstNotNullAsync(); + //var pathInfoList = await _subjectVisitRepository.Where(t => inCommand.SubjectVisitIdList.Contains(t.Id)).Select(t => new { t.TrialId, t.SubjectId, VisitId = t.Id }).ToListAsync(); - foreach (var item in list.GroupBy(t => new { t.StudyInstanceUid, t.DicomStudyId })) + foreach (var item in list.GroupBy(t => new { t.StudyInstanceUid, t.DicomStudyId,t.SubjectVisitId,t.TrialId,t.SubjectId })) { - var ossFolder = $"{pathInfo.TrialId}/Image/{pathInfo.SubjectId}/{pathInfo.VisitId}/{item.Key.StudyInstanceUid}"; + var ossFolder = $"{item.Key.TrialId}/Image/{item.Key.SubjectId}/{item.Key.SubjectVisitId}/{item.Key.StudyInstanceUid}"; var isSucess = await SafeBussinessHelper.RunAsync(async () => await DicomDIRHelper.GenerateStudyDIRAndUploadAsync(item.ToList(), dirDic, ossFolder, _oSSService)); @@ -3529,9 +3529,9 @@ namespace IRaCIS.Application.Services #endregion - var query = from sv in _subjectVisitRepository.Where(t => t.Id == subjectVisitId) + var query = from sv in _subjectVisitRepository.Where(t => inCommand.SubjectVisitIdList.Contains(t.Id)) - select new + select new DownloadVisitDto() { TrialId = sv.TrialId, SubjectId = sv.SubjectId, @@ -3567,9 +3567,9 @@ namespace IRaCIS.Application.Services }; - var result = query.FirstOrDefault(); + var visitList = await query.ToListAsync(); - foreach (var item in result.StudyList.SelectMany(t => t.SeriesList).SelectMany(t => t.InstanceList)) + foreach (var item in visitList.SelectMany(t=>t.StudyList).SelectMany(t => t.SeriesList).SelectMany(t => t.InstanceList)) { var key = item.InstanceId.ToString(); if (dirDic.ContainsKey(key)) @@ -3580,7 +3580,7 @@ namespace IRaCIS.Application.Services var downLoadList = new ImageDownloadLog() { - DownLoadList = result.StudyList + DownLoadList = visitList.SelectMany(t => t.StudyList) .Select(t => new ImageDownloadInfo() { PatientId = t.PatientId, StudyId = t.StudyId, StudyInstanceUid = t.StudyInstanceUid, ImageCount = t.SeriesList.SelectMany(c => c.InstanceList).Count() }).ToList() }; @@ -3590,16 +3590,15 @@ namespace IRaCIS.Application.Services DownLoadListStr = downLoadList.ToJsonStr(), Id = NewId.NextSequentialGuid(), IP = _userInfo.IP, - SubjectVisitId = subjectVisitId, DownloadStartTime = DateTime.Now, - StudyCount = result.StudyList.Count(), - ImageCount = result.StudyList.Sum(s => s.SeriesList.Sum(s => s.InstanceList.Count())), - ImageSize = result.StudyList.Sum(t => t.SeriesList.Sum(s => s.InstanceList.Sum(i => i.FileSize))) ?? 0 + StudyCount = visitList.SelectMany(t => t.StudyList).Count(), + ImageCount = visitList.SelectMany(t => t.StudyList).Sum(s => s.SeriesList.Sum(s => s.InstanceList.Count())), + ImageSize = visitList.SelectMany(t => t.StudyList).Sum(t => t.SeriesList.Sum(s => s.InstanceList.Sum(i => i.FileSize))) ?? 0 }; await _subejctVisitDownloadRepository.AddAsync(preDownloadInfo, true); - return ResponseOutput.Ok(result, preDownloadInfo.Id); + return ResponseOutput.Ok(visitList, preDownloadInfo.Id); } ///