From 87dcebd00bed40ed31660e265b4638ad79f76be8 Mon Sep 17 00:00:00 2001 From: hang <87227557@qq.com> Date: Tue, 23 Dec 2025 20:27:31 +0800 Subject: [PATCH] =?UTF-8?q?post=20=E8=A1=A8=E5=8D=95=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IRC.Core.SCP/Service/CStoreSCPService.cs | 8 +- .../Controllers/UploadDownLoadController.cs | 449 +++++++++++++++++- .../Service/Visit/DTO/PatientViewModel.cs | 2 +- 3 files changed, 453 insertions(+), 6 deletions(-) diff --git a/IRC.Core.SCP/Service/CStoreSCPService.cs b/IRC.Core.SCP/Service/CStoreSCPService.cs index 7819a4366..4ed17cbef 100644 --- a/IRC.Core.SCP/Service/CStoreSCPService.cs +++ b/IRC.Core.SCP/Service/CStoreSCPService.cs @@ -482,9 +482,9 @@ namespace IRaCIS.Core.SCP.Service var frag = dicomFile.Dataset.GetDicomItem(DicomTag.PixelData); - int fragmentCount = frag.Fragments.Count(); + int fragmentCount = frag?.Fragments?.Count()??0; - var originOffsetTable = frag.OffsetTable; //有可能没有表,需要自己重建 + var originOffsetTable = frag?.OffsetTable; //有可能没有表,需要自己重建 var bot = new List(); @@ -572,7 +572,9 @@ namespace IRaCIS.Core.SCP.Service // 替换原 PixelData - dicomFile.Dataset.AddOrUpdate(newFragments); + dicomFile.Dataset.AddOrUpdate(DicomTag.PixelData, newFragments); + + //dicomFile.Dataset.AddOrUpdate(newFragments); // 重新保存 dicom 到流 diff --git a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs index 1cb8faa52..cd6a9c5c2 100644 --- a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs +++ b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs @@ -4,6 +4,7 @@ using ExcelDataReader; using FellowOakDicom; using FellowOakDicom.Imaging; using FellowOakDicom.IO.Buffer; +using Hangfire.Storage; using IRaCIS.Application.Contracts; using IRaCIS.Application.Interfaces; using IRaCIS.Core.API._ServiceExtensions.NewtonsoftJson; @@ -1157,11 +1158,455 @@ namespace IRaCIS.Core.API.Controllers } - [HttpGet("download/PatientStudyBatchDownload")] + [HttpPost("download/PatientStudyBatchDownloadForm")] + public async Task DownloadPatientStudyBatchForm([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService, + [FromServices] IHubContext _downLoadHub, + [FromForm] PatientImageDownloadCommand inCommand) + { + inCommand.SCPStudyIdList = inCommand.SCPStudyIdList.Where(t => t != Guid.Empty).ToList(); + var rusult = await _patientService.GetDownloadPatientStudyInfo(inCommand); + + var patientList = rusult.Data; + var downloadInfo = (SubejctVisitDownload)rusult.OtherData; + + + long receivedSize = 0; + long receivedCount = 0; + long totalSize = downloadInfo.ImageSize; + long totalCount = downloadInfo.ImageCount; + long failedCount = 0; + + 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 + { + FailedCount = failedCount, + TotalCount = totalCount, + TotalSize = (totalSize / 1024 / 1024).ToString("0.00") + " MB", + + 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=Image_{ExportExcelConverterDate.DateTimeInternationalToString(DateTime.Now, _userInfo.TimeZoneId)}.zip"; + Response.Headers["Cache-Control"] = "no-store"; + + + try + { + //开始推送一次 + await NotifyProgressAsync(true); + + await using var responseStream = Response.BodyWriter.AsStream(); + using var zip = new ZipArchive(responseStream, ZipArchiveMode.Create, leaveOpen: true); + + foreach (var patient in patientList) + { + foreach (var study in patient.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 = $"{patient.PatientIdStr}/{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) + { + abortToken.ThrowIfCancellationRequested(); + + try + { + //当前完成大小 + receivedSize = receivedSize + instance.FileSize ?? 0; + receivedCount++; + + + var entryPath = + $"{patient.PatientIdStr}/{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; + + try + { + //对于封装像素的文件做转换 + 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)); + } + + var frag = dicomFile.Dataset.GetDicomItem(DicomTag.PixelData); + + var originOffsetTable = frag?.OffsetTable; + + newFragments.OffsetTable.AddRange(originOffsetTable?.ToArray()); + // 替换原有的片段序列 + dicomFile.Dataset.AddOrUpdate(newFragments); + } + } + catch (Exception ex) + { + + Log.Logger.Warning($"处理多帧合并{instance.Path}失败: {ex.Message}"); + } + + #endregion + + await dicomFile.SaveAsync(entryStream); + //await source.CopyToAsync(entryStream, 32 * 1024, abortToken); + } + catch (Exception ex) + { + failedCount++; + Log.Logger.Warning($"处理文件{instance.Path}失败: {ex.Message}"); + } + finally + { + 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(); + + + } + + [HttpPost("download/PatientStudyBatchDownloadJson")] + public async Task DownloadPatientStudyBatchJson([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService, + [FromServices] IHubContext _downLoadHub, + [FromForm] string jsonDataStr) + { + + var inCommand = JsonConvert.DeserializeObject(jsonDataStr); + + inCommand.SCPStudyIdList = inCommand.SCPStudyIdList.Where(t => t != Guid.Empty).ToList(); + var rusult = await _patientService.GetDownloadPatientStudyInfo(inCommand); + + var patientList = rusult.Data; + var downloadInfo = (SubejctVisitDownload)rusult.OtherData; + + + long receivedSize = 0; + long receivedCount = 0; + long totalSize = downloadInfo.ImageSize; + long totalCount = downloadInfo.ImageCount; + long failedCount = 0; + + 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 + { + FailedCount = failedCount, + TotalCount = totalCount, + TotalSize = (totalSize / 1024 / 1024).ToString("0.00") + " MB", + + 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=Image_{ExportExcelConverterDate.DateTimeInternationalToString(DateTime.Now, _userInfo.TimeZoneId)}.zip"; + Response.Headers["Cache-Control"] = "no-store"; + + + try + { + //开始推送一次 + await NotifyProgressAsync(true); + + await using var responseStream = Response.BodyWriter.AsStream(); + using var zip = new ZipArchive(responseStream, ZipArchiveMode.Create, leaveOpen: true); + + foreach (var patient in patientList) + { + foreach (var study in patient.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 = $"{patient.PatientIdStr}/{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) + { + abortToken.ThrowIfCancellationRequested(); + + try + { + //当前完成大小 + receivedSize = receivedSize + instance.FileSize ?? 0; + receivedCount++; + + + var entryPath = + $"{patient.PatientIdStr}/{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; + + try + { + //对于封装像素的文件做转换 + 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)); + } + + var frag = dicomFile.Dataset.GetDicomItem(DicomTag.PixelData); + + var originOffsetTable = frag?.OffsetTable; + + newFragments.OffsetTable.AddRange(originOffsetTable?.ToArray()); + // 替换原有的片段序列 + dicomFile.Dataset.AddOrUpdate(newFragments); + } + } + catch (Exception ex) + { + + Log.Logger.Warning($"处理多帧合并{instance.Path}失败: {ex.Message}"); + } + + #endregion + + await dicomFile.SaveAsync(entryStream); + //await source.CopyToAsync(entryStream, 32 * 1024, abortToken); + } + catch (Exception ex) + { + failedCount++; + Log.Logger.Warning($"处理文件{instance.Path}失败: {ex.Message}"); + } + finally + { + 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(); + + + } + + + [HttpPost("download/PatientStudyBatchDownload")] public async Task DownloadPatientStudyBatch([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService, [FromServices] IHubContext _downLoadHub, - [FromQuery] PatientImageDownloadCommand inCommand) + PatientImageDownloadCommand inCommand) { + Console.WriteLine(inCommand.ToJsonStr()); + inCommand.SCPStudyIdList = inCommand.SCPStudyIdList.Where(t => t != Guid.Empty).ToList(); var rusult = await _patientService.GetDownloadPatientStudyInfo(inCommand); diff --git a/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs b/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs index 817997967..bc9d7c9f7 100644 --- a/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs +++ b/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs @@ -991,7 +991,7 @@ namespace IRaCIS.Application.Contracts public List SCPStudyIdList { get; set; } = new List(); [NotDefault] - public string CurrentNoticeId { get; set; } + public string CurrentNoticeId { get; set; } = "11"; } public class VisitImageDownloadQuery : PageInput