diff --git a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs index e4bd1cdc1..a47b30c69 100644 --- a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs +++ b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs @@ -1,6 +1,8 @@ using AutoMapper; using ExcelDataReader; +using IRaCIS.Application.Contracts; using IRaCIS.Application.Interfaces; +using IRaCIS.Core.API._ServiceExtensions.NewtonsoftJson; using IRaCIS.Core.Application.BusinessFilter; using IRaCIS.Core.Application.Contracts; using IRaCIS.Core.Application.Contracts.Dicom; @@ -30,12 +32,16 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using MiniExcelLibs; using Newtonsoft.Json; +using NPOI.HPSF; +using Serilog; using SharpCompress.Archives; +using SharpCompress.Common; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Data; using System.IO; +using System.IO.Compression; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -297,7 +303,7 @@ namespace IRaCIS.Core.API.Controllers [HttpPost, Route("Study/ArchiveStudy")] [DisableFormValueModelBinding] [DisableRequestSizeLimit] - [TrialGlobalLimit( "AfterStopCannNotOpt" )] + [TrialGlobalLimit("AfterStopCannNotOpt")] public async Task ArchiveStudyNew(Guid trialId, Guid subjectVisitId, string studyInstanceUid, Guid? abandonStudyId, Guid studyMonitorId, [FromServices] ILogger _logger, [FromServices] IStudyService _studyService, @@ -452,7 +458,7 @@ namespace IRaCIS.Core.API.Controllers /// /// [HttpPost, Route("Study/PreArchiveStudy")] - [TrialGlobalLimit( "AfterStopCannNotOpt" )] + [TrialGlobalLimit("AfterStopCannNotOpt")] public async Task PreArchiveStudy(PreArchiveStudyCommand preArchiveStudyCommand, [FromServices] IStudyService _studyService, [FromServices] IRepository _studyMonitorRepository) @@ -488,7 +494,7 @@ namespace IRaCIS.Core.API.Controllers /// /// [HttpPost("NoneDicomStudy/UploadNoneDicomFile")] - [TrialGlobalLimit( "AfterStopCannNotOpt" )] + [TrialGlobalLimit("AfterStopCannNotOpt")] public async Task UploadNoneDicomFile(UploadNoneDicomFileCommand incommand, [FromServices] IRepository _noneDicomStudyRepository, [FromServices] IRepository _studyMonitorRepository, @@ -560,7 +566,7 @@ namespace IRaCIS.Core.API.Controllers /// /// [HttpPost("QCOperation/UploadVisitCheckExcel/{trialId:guid}")] - [TrialGlobalLimit( "AfterStopCannNotOpt" )] + [TrialGlobalLimit("AfterStopCannNotOpt")] public async Task UploadVisitCheckExcel(Guid trialId, [FromServices] IOSSService oSSService, [FromServices] IRepository _inspectionFileRepository) { @@ -880,7 +886,102 @@ namespace IRaCIS.Core.API.Controllers } + + #endregion + + + [HttpPost("download/PatientStudyBatchDownload")] + public async Task DownloadPatientStudyBatch([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService, + [FromServices] IHubContext _downLoadHub, + PatientImageDownloadCommand inCommand) + { + var rusult = await _patientService.GetDownloadPatientStudyInfo(inCommand); + + var patientList = rusult.Data; + var downloadInfo = (SubejctVisitDownload)rusult.OtherData; + + + long totalSize = downloadInfo.ImageSize; + var abortToken = HttpContext.RequestAborted; + var lastNotify = DateTime.UtcNow; + + 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 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(); + + 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); + + await source.CopyToAsync(entryStream, 32 * 1024, abortToken); + + + //await _downLoadHub.Clients.User(_userInfo.IdentityUserId.ToString()).ReceivProgressAsync(archiveStudyCommand.StudyInstanceUid, receivedCount); + } + } + } + } + + // 正常完成 + 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(); + + + } + } - #endregion } diff --git a/IRaCIS.Core.API/Progranm.cs b/IRaCIS.Core.API/Progranm.cs index be3c8695b..d425d43c9 100644 --- a/IRaCIS.Core.API/Progranm.cs +++ b/IRaCIS.Core.API/Progranm.cs @@ -247,6 +247,7 @@ app.MapMasaMinimalAPIs(); app.MapControllers(); app.MapHub("/UploadHub"); +app.MapHub("/DownloadHub"); app.MapHealthChecks("/health"); diff --git a/IRaCIS.Core.API/SignalRHub/UploadHub.cs b/IRaCIS.Core.API/SignalRHub/UploadHub.cs index 5bcd9b7ac..f102f1460 100644 --- a/IRaCIS.Core.API/SignalRHub/UploadHub.cs +++ b/IRaCIS.Core.API/SignalRHub/UploadHub.cs @@ -3,14 +3,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; +using System; using System.Threading.Tasks; namespace IRaCIS.Core.API { - public interface IUploadClient - { - Task ReceivProgressAsync(string studyInstanceUid, int haveReceivedCount); - } + public class IRaCISUserIdProvider : IUserIdProvider @@ -21,6 +19,11 @@ namespace IRaCIS.Core.API } } + public interface IUploadClient + { + Task ReceivProgressAsync(string studyInstanceUid, int haveReceivedCount); + } + [AllowAnonymous] [DisableCors] public class UploadHub : Hub @@ -53,5 +56,34 @@ namespace IRaCIS.Core.API // await Clients.All.ReceivProgressAsync(studyInstanceUid, haveReceivedCount); //} + } + + + public interface IDownloadClient + { + Task ReceivProgressAsync(Guid downloadId, string percent); + } + + + [AllowAnonymous] + [DisableCors] + public class DownloadHub : Hub + { + + public ILogger _logger { get; set; } + public DownloadHub(ILogger logger) + { + _logger = logger; + } + + public override Task OnConnectedAsync() + { + _logger.LogError("连接: " + Context.ConnectionId); + + return base.OnConnectedAsync(); + } + + + } } diff --git a/IRaCIS.Core.Application/Helper/OSSService.cs b/IRaCIS.Core.Application/Helper/OSSService.cs index ee4d7336b..a9b420c28 100644 --- a/IRaCIS.Core.Application/Helper/OSSService.cs +++ b/IRaCIS.Core.Application/Helper/OSSService.cs @@ -541,16 +541,46 @@ public class OSSService : IOSSService .WithHttpClient(new HttpClient(httpClientHandler)) .Build(); - var memoryStream = new MemoryStream(); + var pipe = new System.IO.Pipelines.Pipe(); - var getObjectArgs = new GetObjectArgs() - .WithBucket(minIOConfig.BucketName) - .WithObject(ossRelativePath) - .WithCallbackStream(stream => stream.CopyToAsync(memoryStream)); + _ = Task.Run(async () => + { + try + { + var args = new GetObjectArgs() + .WithBucket(minIOConfig.BucketName) + .WithObject(ossRelativePath) + .WithCallbackStream( stream => + { + stream.CopyTo(pipe.Writer.AsStream()); + }); + + await minioClient.GetObjectAsync(args); + await pipe.Writer.CompleteAsync(); + } + catch (Exception ex) + { + await pipe.Writer.CompleteAsync(ex); + } + }); + + return pipe.Reader.AsStream(); + + #region 废弃 + + //var memoryStream = new MemoryStream(); + + //var getObjectArgs = new GetObjectArgs() + // .WithBucket(minIOConfig.BucketName) + // .WithObject(ossRelativePath) + // .WithCallbackStream(stream => stream.CopyToAsync(memoryStream)); + + //await minioClient.GetObjectAsync(getObjectArgs); + //memoryStream.Position = 0; + //return memoryStream; + + #endregion - await minioClient.GetObjectAsync(getObjectArgs); - memoryStream.Position = 0; - return memoryStream; } else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") { diff --git a/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs b/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs index 5b88fcd7f..6ac63a26d 100644 --- a/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs +++ b/IRaCIS.Core.Application/Service/Visit/DTO/PatientViewModel.cs @@ -1208,7 +1208,14 @@ namespace IRaCIS.Application.Contracts } + public class DownloadPatientDto + { + public string PatientName { get; set; } + public string PatientIdStr { get; set; } + + public List StudyList { get; set; } + } public class DownloadDicomStudyDto { public Guid StudyId { get; set; } diff --git a/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs b/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs new file mode 100644 index 000000000..d12902fb1 --- /dev/null +++ b/IRaCIS.Core.Application/Service/Visit/Interface/IPatientService.cs @@ -0,0 +1,14 @@ +using IRaCIS.Application.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace IRaCIS.Application.Interfaces +{ + public interface IPatientService + { + + public Task>> GetDownloadPatientStudyInfo(PatientImageDownloadCommand inCommand); + + public Task DownloadImageSuccess(Guid trialImageDownloadId); + + } +} diff --git a/IRaCIS.Core.Application/Service/Visit/PatientService.cs b/IRaCIS.Core.Application/Service/Visit/PatientService.cs index 86f3ef29f..c897ca6a8 100644 --- a/IRaCIS.Core.Application/Service/Visit/PatientService.cs +++ b/IRaCIS.Core.Application/Service/Visit/PatientService.cs @@ -75,7 +75,7 @@ namespace IRaCIS.Application.Services IDistributedLockProvider _distributedLockProvider, IMapper _mapper, IUserInfo _userInfo, IWebHostEnvironment _hostEnvironment, IStringLocalizer _localizer, IFusionCache _fusionCache - ) : BaseService + ) : BaseService, IPatientService { #region 访视提交生成任务了,但是需要退回 @@ -3301,7 +3301,7 @@ namespace IRaCIS.Application.Services /// /// [HttpPost] - public async Task GetDownloadPatientStudyInfo(PatientImageDownloadCommand inCommand) + public async Task>> GetDownloadPatientStudyInfo(PatientImageDownloadCommand inCommand) { var isAdminOrOA = _userInfo.UserTypeEnumInt == (int)UserTypeEnum.Admin || _userInfo.UserTypeEnumInt == (int)UserTypeEnum.OA || _userInfo.UserTypeEnumInt == (int)UserTypeEnum.SuperAdmin; @@ -3384,10 +3384,10 @@ namespace IRaCIS.Application.Services #endregion - var query = _patientRepository.Where(t => patientIdList.Contains(t.Id)).Select(t => new + var query = _patientRepository.Where(t => patientIdList.Contains(t.Id)).Select(t => new DownloadPatientDto() { - t.PatientName, - t.PatientIdStr, + PatientName= t.PatientName, + PatientIdStr= t.PatientIdStr, StudyList = t.SCPStudyList.Where(t => studyIdList.Count > 0 ? studyIdList.Contains(t.Id) : true) .Where(t => isAdminOrOA ? true : t.HospitalGroupList.Any(c => currentUserHospitalGroupIdList.Contains(c.HospitalGroupId))) @@ -3414,7 +3414,7 @@ namespace IRaCIS.Application.Services FileSize = k.FileSize }).ToList() }).ToList() - }) + }).ToList() }); var patientList = await query.ToListAsync(); @@ -3610,7 +3610,7 @@ namespace IRaCIS.Application.Services [HttpGet] public async Task DownloadImageSuccess(Guid trialImageDownloadId) { - await _subejctVisitDownloadRepository.UpdatePartialFromQueryAsync(t => t.Id == trialImageDownloadId, u => new SubejctVisitDownload() + await _subejctVisitDownloadRepository.UpdatePartialFromQueryAsync( trialImageDownloadId, u => new SubejctVisitDownload() { DownloadEndTime = DateTime.Now, IsSuccess = true }, true); return ResponseOutput.Ok(); } diff --git a/IRaCIS.Core.Infrastructure/_IRaCIS/Output/IResponseOutput.cs b/IRaCIS.Core.Infrastructure/_IRaCIS/Output/IResponseOutput.cs index c9f51dece..fa5962c83 100644 --- a/IRaCIS.Core.Infrastructure/_IRaCIS/Output/IResponseOutput.cs +++ b/IRaCIS.Core.Infrastructure/_IRaCIS/Output/IResponseOutput.cs @@ -33,6 +33,8 @@ /// 返回数据 /// T Data { get; set; } + + object OtherData { get; set; } } }