对接联影,转发c-find c-move请求,并且发送c-store 请求
continuous-integration/drone/push Build is passing Details

Test_HIR_Net8
hang 2025-10-27 15:26:49 +08:00
parent e5f6685719
commit e496cf1117
9 changed files with 19699 additions and 15 deletions

View File

@ -26,12 +26,25 @@ using Newtonsoft.Json;
using FellowOakDicom.Imaging.Codec;
using FellowOakDicom.IO.Buffer;
using System.Diagnostics.CodeAnalysis;
using FellowOakDicom.Network.Client;
namespace IRaCIS.Core.SCP.Service
{
public class DicomSCPServiceOption
{
public bool IsSupportThirdService { get; set; }
public string ThirdSearchPacsAE { get; set; }
public string ThirdCallningAE { get; set; }
public string ThirdIP { get; set; }
public int THirdPort { get; set; }
public List<string> CalledAEList { get; set; }
public string ServerPort { get; set; }
@ -51,12 +64,16 @@ namespace IRaCIS.Core.SCP.Service
private SCPImageUpload _upload { get; set; }
private DicomSCPServiceOption DicomSCPServiceConfig { get; set; }
public HospitalGroup CurrentHospitalGroup { get; set; }
private List<Guid> HospitalGroupIdList { get; set; }
private bool _releasedNormally = false;
private bool _isCurrentThirdForward = false;
private static readonly DicomTransferSyntax[] _acceptedTransferSyntaxes = new DicomTransferSyntax[]
{
@ -97,6 +114,7 @@ namespace IRaCIS.Core.SCP.Service
public Task OnReceiveAssociationRequestAsync(DicomAssociation association)
{
_upload = new SCPImageUpload() { StartTime = DateTime.Now, CallingAE = association.CallingAE, CalledAE = association.CalledAE, CallingAEIP = association.RemoteHost };
@ -108,7 +126,7 @@ namespace IRaCIS.Core.SCP.Service
var option = _serviceProvider.GetService<IOptionsMonitor<DicomSCPServiceOption>>().CurrentValue;
DicomSCPServiceConfig = option;
var _hospitalGroupRepository = _serviceProvider.GetService<IRepository<HospitalGroup>>();
@ -180,17 +198,22 @@ namespace IRaCIS.Core.SCP.Service
private async Task AddUploadLogAsync()
{
//记录监控
//转发第三方,那么不记录日志
if (_isCurrentThirdForward == false)
{
//记录监控
var _SCPImageUploadRepository = _serviceProvider.GetService<IRepository<SCPImageUpload>>();
var _SCPImageUploadRepository = _serviceProvider.GetService<IRepository<SCPImageUpload>>();
_upload.EndTime = DateTime.Now;
_upload.StudyCount = _ImageUploadList.Count;
_upload.EndTime = DateTime.Now;
_upload.StudyCount = _ImageUploadList.Count;
_upload.UploadJsonStr = (new SCPImageLog() { UploadList = _ImageUploadList }).ToJsonStr();
_upload.UploadJsonStr = (new SCPImageLog() { UploadList = _ImageUploadList }).ToJsonStr();
//可能是测试echo 导致记录了
await _SCPImageUploadRepository.AddAsync(_upload, _upload.FileCount > 0 ? true : false);
}
//可能是测试echo 导致记录了
await _SCPImageUploadRepository.AddAsync(_upload, _upload.FileCount > 0 ? true : false);
}
@ -292,6 +315,52 @@ namespace IRaCIS.Core.SCP.Service
return new DicomCStoreResponse(request, DicomStatus.Success);
}
var _cmoveStudyRepository = _serviceProvider.GetService<IRepository<CmoveStudy>>();
#region 判断是否转发第三方影像
var cmoveInfo = _cmoveStudyRepository.Where(t => t.StudyInstanceUIDList.Any(c => c == studyInstanceUid)).OrderByDescending(t => t.CreateTime).FirstOrDefault();
//确定是第三方请求
if (cmoveInfo != null && cmoveInfo.CallingAE == DicomSCPServiceConfig.ThirdCallningAE)
{
_isCurrentThirdForward = true;
var _dicomAERepository = _serviceProvider.GetService<IRepository<DicomAE>>();
var hirServer = await _dicomAERepository.FirstOrDefaultAsync(t => t.PacsTypeEnum == PacsType.HIRServer);
try
{
// 克隆 Dataset 避免共享引用
var dsCopy = request.Dataset?.Clone();
var forwardRequest = new DicomCStoreRequest(dsCopy);
// 创建客户端连接到目标 PACS
var client = DicomClientFactory.Create(DicomSCPServiceConfig.ThirdIP, DicomSCPServiceConfig.THirdPort, false, DicomSCPServiceConfig.CalledAEList.First(), cmoveInfo.DestinationAE);
// 可以加入 OnResponseReceived 来处理目标 PACS 返回状态
forwardRequest.OnResponseReceived += (rq, rp) =>
{
Log.Logger.Information($"Forwarded C-STORE Response: {rq.SOPInstanceUID} {rp.Status}");
};
await client.AddRequestAsync(forwardRequest);
await client.SendAsync();
}
catch (Exception ex)
{
Log.Logger.Error("Error forwarding C-STORE: " + ex.Message);
}
return new DicomCStoreResponse(request, DicomStatus.Success);
}
#endregion
//确保来了影像集合存在
if (!_ImageUploadList.Any(t => t.StudyInstanceUid == studyInstanceUid))
{
@ -307,7 +376,7 @@ namespace IRaCIS.Core.SCP.Service
var _seriesRepository = _serviceProvider.GetService<IRepository<SCPSeries>>();
var _studyGroupRepository = _serviceProvider.GetService<IRepository<SCPStudyHospitalGroup>>();
var _cmoveStudyRepository = _serviceProvider.GetService<IRepository<CmoveStudy>>();
var _distributedLockProvider = _serviceProvider.GetService<IDistributedLockProvider>();

View File

@ -24,11 +24,13 @@
"Hangfire": "Server=106.14.89.110,1435;Database=Test_HIR_Hangfire;User ID=sa;Password=xc@123456;TrustServerCertificate=true"
},
"DicomSCPServiceConfig": {
"IsSupportThirdService": true,
"ThirdSearchPacsAE": "ThirdCalledPacsAE",
"ThirdCallningAE": "ThirdCallningAE",
"ThirdIP": "192.168.3.15",
"THirdPort": 112,
"CalledAEList": [
"STORESCP",
"HIRAE",
"Value2",
"Value3"
"HIRAE"
],
"ServerPort": 11112
}

View File

@ -0,0 +1,320 @@
using AutoMapper.Execution;
using FellowOakDicom;
using FellowOakDicom.Network;
using FellowOakDicom.Network.Client;
using IRaCIS.Core.Domain.Models;
using IRaCIS.Core.Infra.EFCore;
using IRaCIS.Core.Infrastructure.Extention;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Bcpg;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace IRaCIS.Core.API.HostService
{
public class DicomSCPServiceOption
{
public bool IsSupportThirdService { get; set; }
public string ThirdSearchPacsAE { get; set; }
public string ThirdCallningAE { get; set; }
public List<string> CalledAEList { get; set; }
public string ServerPort { get; set; }
}
public class DicomSCPService : DicomService, IDicomServiceProvider, IDicomCFindProvider, IDicomCEchoProvider, IDicomCMoveProvider
{
private static readonly DicomTransferSyntax[] _acceptedTransferSyntaxes = new DicomTransferSyntax[]
{
DicomTransferSyntax.ExplicitVRLittleEndian,
DicomTransferSyntax.ExplicitVRBigEndian,
DicomTransferSyntax.ImplicitVRLittleEndian
};
private static readonly DicomTransferSyntax[] _acceptedImageTransferSyntaxes = new DicomTransferSyntax[]
{
// Lossless
DicomTransferSyntax.JPEGLSLossless,
DicomTransferSyntax.JPEG2000Lossless,
DicomTransferSyntax.JPEGProcess14SV1,
DicomTransferSyntax.JPEGProcess14,
DicomTransferSyntax.RLELossless,
// Lossy
DicomTransferSyntax.JPEGLSNearLossless,
DicomTransferSyntax.JPEG2000Lossy,
DicomTransferSyntax.JPEGProcess1,
DicomTransferSyntax.JPEGProcess2_4,
// Uncompressed
DicomTransferSyntax.ExplicitVRLittleEndian,
DicomTransferSyntax.ExplicitVRBigEndian,
DicomTransferSyntax.ImplicitVRLittleEndian
};
private IServiceProvider _serviceProvider { get; set; }
private DicomSCPServiceOption DicomSCPServiceConfig { get; set; }
public string CallingAE { get; protected set; }
public string CalledAE { get; protected set; }
public DicomSCPService(INetworkStream stream, Encoding fallbackEncoding, Microsoft.Extensions.Logging.ILogger log, DicomServiceDependencies dependencies, IServiceProvider injectServiceProvider)
: base(stream, fallbackEncoding, log, dependencies)
{
_serviceProvider = injectServiceProvider.CreateScope().ServiceProvider;
}
public void OnConnectionClosed(Exception exception)
{
throw new NotImplementedException();
}
public void OnReceiveAbort(DicomAbortSource source, DicomAbortReason reason)
{
Logger.LogError($"Received abort from {source}, reason is {reason}");
}
public Task OnReceiveAssociationReleaseRequestAsync()
{
return SendAssociationReleaseResponseAsync();
}
public Task OnReceiveAssociationRequestAsync(DicomAssociation association)
{
CallingAE = association.CallingAE;
CalledAE = association.CalledAE;
Logger.LogInformation($"Received association request from AE: {CallingAE} with IP: {association.RemoteHost} ");
DicomSCPServiceConfig = _serviceProvider.GetService<IOptionsMonitor<DicomSCPServiceOption>>().CurrentValue;
var calledAEList = DicomSCPServiceConfig.CalledAEList;
//不支持三方服务 或者CallAE不对那么拒绝连接
if (!calledAEList.Contains(CalledAE) || DicomSCPServiceConfig.IsSupportThirdService == false || CallingAE != DicomSCPServiceConfig.ThirdCallningAE)
{
Logger.LogError($"Association with {CallingAE} rejected since called aet {CalledAE} is unknown");
return SendAssociationRejectAsync(DicomRejectResult.Permanent, DicomRejectSource.ServiceUser, DicomRejectReason.CalledAENotRecognized);
}
foreach (var pc in association.PresentationContexts)
{
if (pc.AbstractSyntax == DicomUID.Verification
|| pc.AbstractSyntax == DicomUID.PatientRootQueryRetrieveInformationModelFind
|| pc.AbstractSyntax == DicomUID.PatientRootQueryRetrieveInformationModelMove
|| pc.AbstractSyntax == DicomUID.StudyRootQueryRetrieveInformationModelFind
|| pc.AbstractSyntax == DicomUID.StudyRootQueryRetrieveInformationModelMove)
{
pc.AcceptTransferSyntaxes(_acceptedTransferSyntaxes);
}
else if (pc.AbstractSyntax == DicomUID.PatientRootQueryRetrieveInformationModelGet
|| pc.AbstractSyntax == DicomUID.StudyRootQueryRetrieveInformationModelGet)
{
pc.AcceptTransferSyntaxes(_acceptedImageTransferSyntaxes);
}
else if (pc.AbstractSyntax.StorageCategory != DicomStorageCategory.None)
{
pc.AcceptTransferSyntaxes(_acceptedImageTransferSyntaxes);
}
else
{
Logger.LogWarning($"Requested abstract syntax {pc.AbstractSyntax} from {CallingAE} not supported");
pc.SetResult(DicomPresentationContextResult.RejectAbstractSyntaxNotSupported);
}
}
Logger.LogInformation($"Accepted association request from {CallingAE}");
return SendAssociationAcceptAsync(association);
}
public Task<DicomCEchoResponse> OnCEchoRequestAsync(DicomCEchoRequest request)
{
Logger.LogInformation("Received verification request from AE {0} with IP: {1}", CallingAE, Association.RemoteHost);
return Task.FromResult(new DicomCEchoResponse(request, DicomStatus.Success));
}
public async IAsyncEnumerable<DicomCFindResponse> OnCFindRequestAsync(DicomCFindRequest request)
{
Console.WriteLine("Received C-FIND request, forwarding to real PACS...");
var _dicomAERepository = _serviceProvider.GetService<IRepository<DicomAE>>();
var find = await _dicomAERepository.FirstOrDefaultAsync(t => t.PacsTypeEnum == PacsType.PacsServer && t.CalledAE == DicomSCPServiceConfig.ThirdSearchPacsAE);
var hirClient = await _dicomAERepository.FirstOrDefaultAsync(t => t.PacsTypeEnum == PacsType.HIRClient);
if (find == null || hirClient == null)
{
Logger.LogInformation("客户端和Pacs配置未查询到");
}
// 创建 channel 用于异步传递响应
var channel = Channel.CreateUnbounded<DicomCFindResponse>();
// 克隆 dataset 避免线程/状态冲突
var clonedDataset = request.Dataset?.Clone() ?? new DicomDataset();
var forward = new DicomCFindRequest(request.SOPClassUID, request.Level)
{
Dataset = clonedDataset
};
// 标记是否已收到 final 状态Success/Failure/Cancel
var finalReceived = false;
// 当远端 PACS 返回响应时,异步写入 channel
forward.OnResponseReceived += (rq, rp) =>
{
var dsCopy = rp.Dataset?.Clone();
var proxyResp = new DicomCFindResponse(request, rp.Status)
{
Dataset = dsCopy
};
channel.Writer.TryWrite(proxyResp);
if (!rp.Status.Equals(DicomStatus.Pending))
{
finalReceived = true;
}
};
// 异步发送到真实 PACS
_ = Task.Run(async () =>
{
try
{
var client = DicomClientFactory.Create(find.IP, find.Port, false, hirClient.CalledAE, find.CalledAE);
await client.AddRequestAsync(forward);
await client.SendAsync();
}
catch (Exception ex)
{
Console.WriteLine("Error forwarding C-FIND: " + ex.Message);
}
finally
{
channel.Writer.Complete();
}
});
// 异步 yield 返回给上游
await foreach (var resp in channel.Reader.ReadAllAsync())
{
yield return resp;
}
// 兜底:如果没有 final 响应,返回 Success
if (!finalReceived)
{
yield return new DicomCFindResponse(request, DicomStatus.Success);
}
}
public async IAsyncEnumerable<DicomCMoveResponse> OnCMoveRequestAsync(DicomCMoveRequest request)
{
Console.WriteLine("Received C-Move request, forwarding to real PACS...");
var _dicomAERepository = _serviceProvider.GetService<IRepository<DicomAE>>();
var _cmoveStudyRepository = _serviceProvider.GetService<IRepository<CmoveStudy>>();
var find = await _dicomAERepository.FirstOrDefaultAsync(t => t.PacsTypeEnum == PacsType.PacsServer && t.CalledAE == DicomSCPServiceConfig.ThirdSearchPacsAE);
var hirServer = await _dicomAERepository.FirstOrDefaultAsync(t => t.PacsTypeEnum == PacsType.HIRServer);
var hirClient = await _dicomAERepository.FirstOrDefaultAsync(t => t.PacsTypeEnum == PacsType.HIRClient);
if (find == null || hirClient == null || hirServer == null)
{
Logger.LogInformation("客户端和Pacs配置未查询到");
}
var studyInstanceUid = request.Dataset?.GetSingleValueOrDefault(DicomTag.StudyInstanceUID, string.Empty);
if (studyInstanceUid.IsNotNullOrEmpty())
{
await _cmoveStudyRepository.AddAsync(new CmoveStudy() { CallingAE = CallingAE, CalledAE = CalledAE, DestinationAE = request.DestinationAE, StudyInstanceUIDList = new List<string>() { studyInstanceUid }, HopitalGroupIdList = new List<Guid>() }, true);
}
var channel = Channel.CreateUnbounded<DicomCMoveResponse>();
var clonedDataset = request.Dataset?.Clone() ?? new DicomDataset();
var forward = new DicomCMoveRequest(hirServer.CalledAE, studyInstanceUid)
{
Dataset = clonedDataset
};
bool finalReceived = false;
// PACS 返回响应时写入 channel
forward.OnResponseReceived += (rq, rp) =>
{
var dsCopy = rp.Dataset?.Clone();
var proxyResp = new DicomCMoveResponse(request, rp.Status)
{
Dataset = dsCopy,
Remaining = rp.Remaining,
Completed = rp.Completed,
};
channel.Writer.TryWrite(proxyResp);
if (!rp.Status.Equals(DicomStatus.Pending))
{
finalReceived = true;
}
};
// 异步发送到真实 PACS
_ = Task.Run(async () =>
{
try
{
var client = DicomClientFactory.Create(find.IP, find.Port, false, hirClient.CalledAE, find.CalledAE);
await client.AddRequestAsync(forward);
await client.SendAsync();
}
catch (Exception ex)
{
Console.WriteLine("Error forwarding C-MOVE: " + ex.Message);
}
finally
{
channel.Writer.Complete();
}
});
// 异步 yield 回上游
await foreach (var resp in channel.Reader.ReadAllAsync())
{
yield return resp;
}
// 兜底
if (!finalReceived)
{
yield return new DicomCMoveResponse(request, DicomStatus.Success);
}
}
}
}

View File

@ -1,4 +1,6 @@
using IRaCIS.Core.API;
using FellowOakDicom.Network;
using FellowOakDicom;
using IRaCIS.Core.API;
using IRaCIS.Core.API.HostService;
using IRaCIS.Core.Application.BusinessFilter;
using IRaCIS.Core.Application.Filter;
@ -126,7 +128,7 @@ builder.Services.AddhangfireSetup(_configuration);
//Dicom影像渲染图片 跨平台
builder.Services.AddDicomSetup();
//builder.Services.AddDicomSetup();
// 实时应用
builder.Services.AddSignalR();
@ -272,6 +274,13 @@ try
//Log.Logger.Warning($"ContentRootPath——xx{Path.GetDirectoryName(Path.GetDirectoryName(env.ContentRootPath))}");
#endregion
DicomSetupBuilder.UseServiceProvider(app.Services);
var logger = app.Services.GetService<Microsoft.Extensions.Logging.ILogger<Program>>();
var server = DicomServerFactory.Create<DicomSCPService>(_configuration.GetSection("DicomSCPServiceConfig").GetValue<int>("ServerPort"), userState: app.Services, logger: logger);
app.Run();

View File

@ -53,5 +53,15 @@
"AuthorizationCode": "zhanying123",
"SiteUrl": "http://hir.test.extimaging.com/login",
"EmailRegexStr": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
},
"DicomSCPServiceConfig": {
"IsSupportThirdService": true,
"ThirdSearchPacsAE": "ThirdCalledPacsAE",
"ThirdCallningAE": "ThirdCallningAE",
"CalledAEList": [
"HIRSCU",
"HIRSCPAE"
],
"ServerPort": 11112
}
}

View File

@ -229,5 +229,9 @@ namespace IRaCIS.Core.Domain.Models
public List<Guid> HopitalGroupIdList { get; set; }
[Comment("CStore-转发到目的地AE")]
public string DestinationAE { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IRaCIS.Core.Infra.EFCore.Migrations
{
/// <inheritdoc />
public partial class cstoreAddFiled : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DestinationAE",
table: "CmoveStudy",
type: "nvarchar(400)",
maxLength: 400,
nullable: false,
defaultValue: "",
comment: "CStore-转发到目的地AE");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DestinationAE",
table: "CmoveStudy");
}
}
}

View File

@ -514,6 +514,12 @@ namespace IRaCIS.Core.Infra.EFCore.Migrations
b.Property<Guid>("CreateUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DestinationAE")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("nvarchar(400)")
.HasComment("CStore-转发到目的地AE");
b.Property<string>("HopitalGroupIdList")
.IsRequired()
.HasColumnType("nvarchar(max)");