HIR 流式写入zip 返回前端
continuous-integration/drone/push Build is passing Details

Test_HIR_Net8
hang 2025-12-17 13:49:33 +08:00
parent 93362ea4c9
commit d78a42dead
8 changed files with 211 additions and 24 deletions

View File

@ -1,6 +1,8 @@
using AutoMapper; using AutoMapper;
using ExcelDataReader; using ExcelDataReader;
using IRaCIS.Application.Contracts;
using IRaCIS.Application.Interfaces; using IRaCIS.Application.Interfaces;
using IRaCIS.Core.API._ServiceExtensions.NewtonsoftJson;
using IRaCIS.Core.Application.BusinessFilter; using IRaCIS.Core.Application.BusinessFilter;
using IRaCIS.Core.Application.Contracts; using IRaCIS.Core.Application.Contracts;
using IRaCIS.Core.Application.Contracts.Dicom; using IRaCIS.Core.Application.Contracts.Dicom;
@ -30,12 +32,16 @@ using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using MiniExcelLibs; using MiniExcelLibs;
using Newtonsoft.Json; using Newtonsoft.Json;
using NPOI.HPSF;
using Serilog;
using SharpCompress.Archives; using SharpCompress.Archives;
using SharpCompress.Common;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data; using System.Data;
using System.IO; using System.IO;
using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -880,7 +886,102 @@ namespace IRaCIS.Core.API.Controllers
} }
}
#endregion #endregion
[HttpPost("download/PatientStudyBatchDownload")]
public async Task<IActionResult> DownloadPatientStudyBatch([FromServices] IPatientService _patientService, [FromServices] IOSSService _oSSService,
[FromServices] IHubContext<DownloadHub, IDownloadClient> _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();
}
}
} }

View File

@ -247,6 +247,7 @@ app.MapMasaMinimalAPIs();
app.MapControllers(); app.MapControllers();
app.MapHub<UploadHub>("/UploadHub"); app.MapHub<UploadHub>("/UploadHub");
app.MapHub<DownloadHub>("/DownloadHub");
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");

View File

@ -3,14 +3,12 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace IRaCIS.Core.API namespace IRaCIS.Core.API
{ {
public interface IUploadClient
{
Task ReceivProgressAsync(string studyInstanceUid, int haveReceivedCount);
}
public class IRaCISUserIdProvider : IUserIdProvider public class IRaCISUserIdProvider : IUserIdProvider
@ -21,6 +19,11 @@ namespace IRaCIS.Core.API
} }
} }
public interface IUploadClient
{
Task ReceivProgressAsync(string studyInstanceUid, int haveReceivedCount);
}
[AllowAnonymous] [AllowAnonymous]
[DisableCors] [DisableCors]
public class UploadHub : Hub<IUploadClient> public class UploadHub : Hub<IUploadClient>
@ -53,5 +56,34 @@ namespace IRaCIS.Core.API
// await Clients.All.ReceivProgressAsync(studyInstanceUid, haveReceivedCount); // await Clients.All.ReceivProgressAsync(studyInstanceUid, haveReceivedCount);
//} //}
}
public interface IDownloadClient
{
Task ReceivProgressAsync(Guid downloadId, string percent);
}
[AllowAnonymous]
[DisableCors]
public class DownloadHub : Hub<IDownloadClient>
{
public ILogger<UploadHub> _logger { get; set; }
public DownloadHub(ILogger<UploadHub> logger)
{
_logger = logger;
}
public override Task OnConnectedAsync()
{
_logger.LogError("连接: " + Context.ConnectionId);
return base.OnConnectedAsync();
}
} }
} }

View File

@ -541,16 +541,46 @@ public class OSSService : IOSSService
.WithHttpClient(new HttpClient(httpClientHandler)) .WithHttpClient(new HttpClient(httpClientHandler))
.Build(); .Build();
var memoryStream = new MemoryStream(); var pipe = new System.IO.Pipelines.Pipe();
var getObjectArgs = new GetObjectArgs() _ = Task.Run(async () =>
{
try
{
var args = new GetObjectArgs()
.WithBucket(minIOConfig.BucketName) .WithBucket(minIOConfig.BucketName)
.WithObject(ossRelativePath) .WithObject(ossRelativePath)
.WithCallbackStream(stream => stream.CopyToAsync(memoryStream)); .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") else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS")
{ {

View File

@ -1208,7 +1208,14 @@ namespace IRaCIS.Application.Contracts
} }
public class DownloadPatientDto
{
public string PatientName { get; set; }
public string PatientIdStr { get; set; }
public List<DownloadDicomStudyDto> StudyList { get; set; }
}
public class DownloadDicomStudyDto public class DownloadDicomStudyDto
{ {
public Guid StudyId { get; set; } public Guid StudyId { get; set; }

View File

@ -0,0 +1,14 @@
using IRaCIS.Application.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace IRaCIS.Application.Interfaces
{
public interface IPatientService
{
public Task<IResponseOutput<List<DownloadPatientDto>>> GetDownloadPatientStudyInfo(PatientImageDownloadCommand inCommand);
public Task<IResponseOutput> DownloadImageSuccess(Guid trialImageDownloadId);
}
}

View File

@ -75,7 +75,7 @@ namespace IRaCIS.Application.Services
IDistributedLockProvider _distributedLockProvider, IMapper _mapper, IUserInfo _userInfo, IWebHostEnvironment _hostEnvironment, IStringLocalizer _localizer, IFusionCache _fusionCache IDistributedLockProvider _distributedLockProvider, IMapper _mapper, IUserInfo _userInfo, IWebHostEnvironment _hostEnvironment, IStringLocalizer _localizer, IFusionCache _fusionCache
) : BaseService ) : BaseService, IPatientService
{ {
#region 访视提交生成任务了,但是需要退回 #region 访视提交生成任务了,但是需要退回
@ -3301,7 +3301,7 @@ namespace IRaCIS.Application.Services
/// <param name="inCommand"></param> /// <param name="inCommand"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost] [HttpPost]
public async Task<IResponseOutput> GetDownloadPatientStudyInfo(PatientImageDownloadCommand inCommand) public async Task<IResponseOutput<List<DownloadPatientDto>>> GetDownloadPatientStudyInfo(PatientImageDownloadCommand inCommand)
{ {
var isAdminOrOA = _userInfo.UserTypeEnumInt == (int)UserTypeEnum.Admin || _userInfo.UserTypeEnumInt == (int)UserTypeEnum.OA || _userInfo.UserTypeEnumInt == (int)UserTypeEnum.SuperAdmin; 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 #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, PatientName= t.PatientName,
t.PatientIdStr, PatientIdStr= t.PatientIdStr,
StudyList = t.SCPStudyList.Where(t => studyIdList.Count > 0 ? studyIdList.Contains(t.Id) : true) 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))) .Where(t => isAdminOrOA ? true : t.HospitalGroupList.Any(c => currentUserHospitalGroupIdList.Contains(c.HospitalGroupId)))
@ -3414,7 +3414,7 @@ namespace IRaCIS.Application.Services
FileSize = k.FileSize FileSize = k.FileSize
}).ToList() }).ToList()
}).ToList() }).ToList()
}) }).ToList()
}); });
var patientList = await query.ToListAsync(); var patientList = await query.ToListAsync();
@ -3610,7 +3610,7 @@ namespace IRaCIS.Application.Services
[HttpGet] [HttpGet]
public async Task<IResponseOutput> DownloadImageSuccess(Guid trialImageDownloadId) public async Task<IResponseOutput> 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); { DownloadEndTime = DateTime.Now, IsSuccess = true }, true);
return ResponseOutput.Ok(); return ResponseOutput.Ok();
} }

View File

@ -33,6 +33,8 @@
/// 返回数据 /// 返回数据
/// </summary> /// </summary>
T Data { get; set; } T Data { get; set; }
object OtherData { get; set; }
} }
} }