diff --git a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs index 362497a18..94e7d549d 100644 --- a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs +++ b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs @@ -903,7 +903,7 @@ namespace IRaCIS.Core.API.Controllers Response.Headers["Cache-Control"] = "no-store"; // ⚠️ 关键:直接用 Response.Body - using var zip = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create,leaveOpen: true); + using var zip = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create, leaveOpen: true); // 本地大文件路径 var files = new[] @@ -919,7 +919,7 @@ namespace IRaCIS.Core.API.Controllers var entryName = Path.GetFileName(phyFilePath); - var entry = zip.CreateEntry( entryName, + var entry = zip.CreateEntry(entryName, CompressionLevel.Fastest); // 大文件建议 Fastest await using var entryStream = entry.Open(); @@ -938,6 +938,214 @@ namespace IRaCIS.Core.API.Controllers } + [HttpPost("download/GetPatientStudyBatchDownload")] + public async Task GetDownloadPatientStudyBatch([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService, + [FromServices] IHubContext _downLoadHub, + [FromQuery] 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 frag = dicomFile.Dataset.GetDicomItem(DicomTag.PixelData); + + var originOffsetTable = frag.OffsetTable; + //获取像素是否为封装形式 + 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)); + } + + newFragments.OffsetTable.AddRange(originOffsetTable.ToArray()); + // 替换原有的片段序列 + dicomFile.Dataset.AddOrUpdate(newFragments); + } + + #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,