From 3bca3067026f53912c039fe0331aed1058ab8568 Mon Sep 17 00:00:00 2001 From: hang <872297557@qq.com> Date: Wed, 11 Mar 2026 16:46:00 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=86=85=E5=AD=98?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=E9=98=9F=E5=88=97=E3=80=81=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E4=B8=8A=E4=BC=A0=E7=9A=84=E5=9C=B0=E6=96=B9=E9=83=BD?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=B1=BB=E5=88=AB=EF=BC=8C=E5=92=8C=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=8F=82=E6=95=B0=E3=80=81=E5=A2=9E=E5=8A=A0=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=90=8E=E5=8F=B0=E6=81=A2=E5=A4=8D=E9=98=9F=E5=88=97?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HostConfig/SyncFileRecoveryService.cs | 135 ++ IRC.Core.SCP/Program.cs | 3 + IRC.Core.SCP/Service/CStoreSCPService.cs | 4 +- IRC.Core.SCP/Service/CacheHelper.cs | 2 + .../Service/FileUploadRecordService.cs | 193 +++ IRC.Core.SCP/Service/OSSService.cs | 1474 +++++++++++++++-- .../Controllers/ExtraController.cs | 2 +- .../Controllers/UploadDownLoadController.cs | 7 +- .../HostService/SyncFileRecoveryService.cs | 134 ++ IRaCIS.Core.API/Progranm.cs | 3 + IRaCIS.Core.API/appsettings.Test_IRC.json | 11 +- IRaCIS.Core.Application/Helper/CacheHelper.cs | 10 +- .../Helper/DicomDIRHelper.cs | 14 +- IRaCIS.Core.Application/Helper/OSSService.cs | 222 ++- .../IRaCIS.Core.Application.xml | 44 +- .../Consumer/ConsistencyCheckConsumer.cs | 7 +- .../Common/DTO/FileUploadRecordViewModel.cs | 107 ++ .../Service/Common/EmailLogService.cs | 3 +- .../Service/Common/FileUploadRecordService.cs | 292 ++++ .../Interface/IFileUploadRecordService.cs | 23 + .../Service/Common/_MapConfig.cs | 3 + .../ImageAndDoc/DownloadAndUploadService.cs | 2 +- .../General/GeneralCalculateService.cs | 71 +- .../General/ReadingCalculateService.cs | 48 +- .../TrialSiteUser/TrialConfigService.cs | 6 +- .../{Image => Common}/FileUploadRecord.cs | 24 +- .../AuthUser/IUserInfo.cs | 2 + IRaCIS.Core.Infra.EFCore/AuthUser/UserInfo.cs | 16 +- 28 files changed, 2630 insertions(+), 232 deletions(-) create mode 100644 IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs create mode 100644 IRC.Core.SCP/Service/FileUploadRecordService.cs create mode 100644 IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs create mode 100644 IRaCIS.Core.Application/Service/Common/DTO/FileUploadRecordViewModel.cs create mode 100644 IRaCIS.Core.Application/Service/Common/FileUploadRecordService.cs create mode 100644 IRaCIS.Core.Application/Service/Common/Interface/IFileUploadRecordService.cs rename IRaCIS.Core.Domain/{Image => Common}/FileUploadRecord.cs (87%) diff --git a/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs b/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs new file mode 100644 index 000000000..a14ef5c3c --- /dev/null +++ b/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs @@ -0,0 +1,135 @@ +using IRaCIS.Core.Application.Helper; +using IRaCIS.Core.Application.Service; +using IRaCIS.Core.Domain.Models; +using IRaCIS.Core.Infra.EFCore; +using IRaCIS.Core.SCP.Service; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace IRaCIS.Core.SCP; + + +public class SyncFileRecoveryService(IServiceScopeFactory _scopeFactory, FileSyncQueue _fileSyncQueue) : BackgroundService +{ + + private readonly int _pageSize = 500; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var scope = _scopeFactory.CreateScope(); + var fileUploadRecordRepository = scope.ServiceProvider.GetRequiredService>(); + + // 延迟启动,保证主机快速启动 + await Task.Delay(5000, stoppingToken); + + int page = 0; + + + while (!stoppingToken.IsCancellationRequested) + { + // 分页获取未入队任务 + var pending = await fileUploadRecordRepository + .Where(x => x.IsNeedSync == true && (x.IsSync == false || x.IsSync == null)) + .OrderByDescending(x => x.Priority) + .Select(t => new { t.Id, t.Priority }) + .Skip(page * _pageSize) + .Take(_pageSize) + .ToListAsync(stoppingToken); + + if (!pending.Any()) + break; // 扫描完毕,退出循环 + + foreach (var file in pending) + { + //file.IsQueued = true; // 避免重复入队 + _fileSyncQueue.Enqueue(file.Id, file.Priority ?? 0); // 放入队列 + } + + page++; // 下一页 + await Task.Delay(200, stoppingToken); // 缓解数据库压力 + } + } +} + +public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger _logger, FileSyncQueue _fileSyncQueue) : BackgroundService +{ + + // ⭐ 自动根据服务器CPU + private readonly int _workerCount = Math.Max(1, Environment.ProcessorCount - 1); + + + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + for (int i = 0; i < _workerCount; i++) + Task.Run(() => WorkerLoop(stoppingToken)); + + return Task.CompletedTask; + } + + + private async Task WorkerLoop(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var id = await _fileSyncQueue.DequeueAsync(stoppingToken); + + try + { + using var scope = _scopeFactory.CreateScope(); + var _fileUploadRecordRepository = scope.ServiceProvider.GetRequiredService>(); + var _uploadFileSyncRecordRepository = scope.ServiceProvider.GetRequiredService>(); + var oss = scope.ServiceProvider.GetRequiredService(); + + var file = await _fileUploadRecordRepository.FirstOrDefaultAsync(t => t.Id == id); + + if (file == null || file.IsNeedSync != true) + return; + + var log = new UploadFileSyncRecord + { + FileUploadRecordId = id, + StartTime = DateTime.Now, + JobState = jobState.RUNNING + }; + + await _uploadFileSyncRecordRepository.AddAsync(log); + await _uploadFileSyncRecordRepository.SaveChangesAsync(stoppingToken); + + try + { + await oss.SyncFileAsync(file.Path.TrimStart("/"), file.UploadRegion == "CN" ? ObjectStoreUse.AliyunOSS : ObjectStoreUse.AWS, file.UploadRegion == "CN" ? ObjectStoreUse.AWS : ObjectStoreUse.AliyunOSS); + + file.IsSync = true; + file.SyncFinishedTime = DateTime.UtcNow; + + log.JobState = jobState.SUCCESS; + } + catch (Exception ex) + { + log.JobState = jobState.FAILED; + + log.Msg = ex.Message[..300]; + } + + log.EndTime = DateTime.UtcNow; + + await _uploadFileSyncRecordRepository.SaveChangesAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Sync failed {Id}", id); + } + } + + + } +} + + diff --git a/IRC.Core.SCP/Program.cs b/IRC.Core.SCP/Program.cs index dfffed1ec..bd306d5b8 100644 --- a/IRC.Core.SCP/Program.cs +++ b/IRC.Core.SCP/Program.cs @@ -63,6 +63,9 @@ builder.Host #region 配置服务 var _configuration = builder.Configuration; +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + //健康检查 builder.Services.AddHealthChecks(); diff --git a/IRC.Core.SCP/Service/CStoreSCPService.cs b/IRC.Core.SCP/Service/CStoreSCPService.cs index 820e5c7f8..224d2ca24 100644 --- a/IRC.Core.SCP/Service/CStoreSCPService.cs +++ b/IRC.Core.SCP/Service/CStoreSCPService.cs @@ -644,7 +644,7 @@ namespace IRaCIS.Core.SCP.Service ms.Position = 0; //irc 从路径最后一截取Guid - storeRelativePath = await ossService.UploadToOSSAsync(ms, ossFolderPath, instanceId.ToString(), false); + storeRelativePath = await ossService.UploadToOSSAsync(ms, ossFolderPath, instanceId.ToString(), false, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = _trialId, BatchDataType = BatchDataType.PACSReceive }); fileSize = ms.Length; @@ -674,7 +674,7 @@ namespace IRaCIS.Core.SCP.Service // 上传缩略图到 OSS - var seriesPath = await ossService.UploadToOSSAsync(memoryStream, ossFolderPath, $"{seriesId.ToString()}_{instanceId.ToString()}.preview.jpg", false); + var seriesPath = await ossService.UploadToOSSAsync(memoryStream, ossFolderPath, $"{seriesId.ToString()}_{instanceId.ToString()}.preview.jpg", false,uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = _trialId, BatchDataType = BatchDataType.PACSReceive }); series.ImageResizePath = seriesPath; diff --git a/IRC.Core.SCP/Service/CacheHelper.cs b/IRC.Core.SCP/Service/CacheHelper.cs index 590cd6a20..a0a1ba7fe 100644 --- a/IRC.Core.SCP/Service/CacheHelper.cs +++ b/IRC.Core.SCP/Service/CacheHelper.cs @@ -69,6 +69,8 @@ public static class CacheKeys public static string UserMFAVerifyPass(Guid userId, string browserFingerprint) => $"UserMFAVerifyPass:{userId}:{browserFingerprint}"; public static string TrialSiteInfo(Guid trialSiteId) => $"{trialSiteId}TrialSiteInfo"; + + public static string TrialDataStoreType(Guid trialId) => $"TrialDataStoreType:{trialId}"; } public static class CacheHelper diff --git a/IRC.Core.SCP/Service/FileUploadRecordService.cs b/IRC.Core.SCP/Service/FileUploadRecordService.cs new file mode 100644 index 000000000..27b4a6899 --- /dev/null +++ b/IRC.Core.SCP/Service/FileUploadRecordService.cs @@ -0,0 +1,193 @@ + +//-------------------------------------------------------------------- +// 此代码由liquid模板自动生成 byzhouhang 20240909 +// 生成时间 2026-03-10 06:15:17Z +// 对此文件的更改可能会导致不正确的行为,并且如果重新生成代码,这些更改将会丢失。 +//-------------------------------------------------------------------- +using AutoMapper; +using IRaCIS.Core.Application.Helper; + +using IRaCIS.Core.Domain.Models; +using IRaCIS.Core.Domain.Share; +using IRaCIS.Core.Infra.EFCore; +using IRaCIS.Core.Infra.EFCore.Common; +using IRaCIS.Core.Infrastructure.Extention; +using IRaCIS.Core.SCP; +using IRaCIS.Core.SCP.Service; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using System.Drawing; +using System.Threading.Channels; +using System.Threading.Tasks; +using ZiggyCreatures.Caching.Fusion; + +namespace IRaCIS.Core.SCP.Service; + +public class FileUploadRecordAddOrEdit +{ + public Guid? Id { get; set; } + + + public string FileName { get; set; } + + public long FileSize { get; set; } + + public string FileType { get; set; } + + public string Path { get; set; } + + + public string UploadBatchId { get; set; } + public BatchDataType BatchDataType { get; set; } + + + public Guid? TrialId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? SubjectVisitId { get; set; } + + public Guid? DicomStudyId { get; set; } + + public Guid? NoneDicomStudyId { get; set; } + + + + public string FileMarkId { get; set; } + + public int? Priority { get; set; } + public string IP { get; set; } + public bool? IsNeedSync { get; set; } + public string UploadRegion { get; set; } + public string TargetRegion { get; set; } +} +public interface IFileUploadRecordService +{ + + + Task AddOrUpdateFileUploadRecord(FileUploadRecordAddOrEdit addOrEditFileUploadRecord); + +} + +[ApiExplorerSettings(GroupName = "Common")] +public class FileUploadRecordService(IRepository _fileUploadRecordRepository, + IMapper _mapper, IUserInfo _userInfo, IStringLocalizer _localizer, IOptionsMonitor options, + IFusionCache _fusionCache, IRepository _trialRepository, FileSyncQueue _fileSyncQueue) : BaseService, IFileUploadRecordService +{ + + ObjectStoreServiceOptions ObjectStoreServiceConfig = options.CurrentValue; + + + + + + + public async Task AddOrUpdateFileUploadRecord(FileUploadRecordAddOrEdit addOrEditFileUploadRecord) + { + + addOrEditFileUploadRecord.IP = _userInfo.IP; + + if (ObjectStoreServiceConfig.IsOpenStoreSync && _userInfo.Domain.IsNotNullOrEmpty()) + { + var find = ObjectStoreServiceConfig.SyncConfigList.FirstOrDefault(t => t.Domain == _userInfo.Domain); + if (find != null) + { + addOrEditFileUploadRecord.UploadRegion = find.UploadRegion; + addOrEditFileUploadRecord.TargetRegion = find.TargetRegion; + + } + } + + if (addOrEditFileUploadRecord.TrialId != null) + { + + var trialDataStore = await _fusionCache.GetOrSetAsync(CacheKeys.TrialDataStoreType(addOrEditFileUploadRecord.TrialId.Value), async _ => + { + return await _trialRepository.Where(t => t.Id == addOrEditFileUploadRecord.TrialId).Select(t => t.TrialDataStoreType) + .FirstOrDefaultAsync(); + }, + TimeSpan.FromDays(7) + ); + + //项目配置了,那么就设置需要同步 + if (trialDataStore == TrialDataStore.MUtiCenter) + { + addOrEditFileUploadRecord.IsNeedSync = true; + + addOrEditFileUploadRecord.Priority = 0; + + + } + else + { + addOrEditFileUploadRecord.TargetRegion = ""; + + } + + } + else + { + //系统文件,默认同步 + addOrEditFileUploadRecord.IsNeedSync = true; + + addOrEditFileUploadRecord.Priority = 0; + } + + var entity = await _fileUploadRecordRepository.InsertOrUpdateAsync(addOrEditFileUploadRecord, true); + + if (addOrEditFileUploadRecord.IsNeedSync == true) + { + _fileSyncQueue.Enqueue(entity.Id, addOrEditFileUploadRecord.Priority ?? 0); + } + + return ResponseOutput.Ok(entity.Id.ToString()); + + } + + + + +} + + + + +/// +/// 同步队列 信号量 +/// +public class FileSyncQueue +{ + private readonly PriorityQueue _queue = new(); + private readonly SemaphoreSlim _signal = new(0); + private readonly object _lock = new(); + + public void Enqueue(Guid id, int priority) + { + lock (_lock) + { + // priority 越大越优先 + _queue.Enqueue(id, -priority); + } + + //类似于计数器,不会产生通知风暴,可消费资源 +1 + //if (有等待线程) 唤醒一个 else 仅增加计数 + _signal.Release(); // 唤醒一个 worker + } + + /// + /// 如果没有任务 → 挂起等待 有任务 → 被唤醒并返回 + /// + /// + /// + public async Task DequeueAsync(CancellationToken ct) + { + await _signal.WaitAsync(ct); + + lock (_lock) + { + return _queue.Dequeue(); + } + } +} \ No newline at end of file diff --git a/IRC.Core.SCP/Service/OSSService.cs b/IRC.Core.SCP/Service/OSSService.cs index 1ac5e80ee..efea4770a 100644 --- a/IRC.Core.SCP/Service/OSSService.cs +++ b/IRC.Core.SCP/Service/OSSService.cs @@ -1,5 +1,9 @@ using AlibabaCloud.SDK.Sts20150401; using Aliyun.OSS; +using Aliyun.OSS.Common; +using Amazon; +using AlibabaCloud.SDK.Sts20150401; +using Aliyun.OSS; using Amazon; using Amazon.Runtime; using Amazon.S3; @@ -14,6 +18,11 @@ using Minio; using Minio.DataModel.Args; using System.Reactive.Linq; using System.Runtime.InteropServices; +using Minio.Exceptions; +using IRaCIS.Core.SCP.Service; +using Serilog; +using System.Web; +using IRaCIS.Core.Infrastructure.Extention; namespace IRaCIS.Core.SCP; @@ -59,7 +68,7 @@ public class AliyunOSSOptions public int DurationSeconds { get; set; } - + public string PreviewEndpoint { get; set; } } @@ -74,6 +83,28 @@ public class ObjectStoreServiceOptions public AWSOptions AWS { get; set; } + public bool IsOpenStoreSync { get; set; } + + public string ApiDeployRegion { get; set; } + + public List SyncConfigList { get; set; } = new List(); + +} + +public class SyncStoreConfig +{ + public string Domain { get; set; } + + public string UploadRegion { get; set; } + + public string TargetRegion { get; set; } + + public string Primary { get; set; } + + public string Target { get; set; } + + public bool IsOpenSync { get; set; } + } public class ObjectStoreDTO @@ -87,6 +118,10 @@ public class ObjectStoreDTO public AWSTempToken AWS { get; set; } + public bool IsOpenStoreSync { get; set; } + + public List SyncConfigList { get; set; } + } [LowerCamelCaseJson] @@ -105,6 +140,9 @@ public class AliyunOSSTempToken public string SecurityToken { get; set; } public DateTime Expiration { get; set; } + public string PreviewEndpoint { get; set; } + + public string DownloadEndPoint => EndPoint.Insert(EndPoint.IndexOf("//") + 2, BucketName + "."); } @@ -118,7 +156,7 @@ public class AWSTempToken public string SecretAccessKey { get; set; } public string BucketName { get; set; } public string ViewEndpoint { get; set; } - public DateTime Expiration { get; set; } + public DateTime? Expiration { get; set; } } public enum ObjectStoreUse @@ -128,42 +166,570 @@ public enum ObjectStoreUse AWS = 2, } + #endregion // aws 参考链接 https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/dotnetv3/S3/S3_Basics public interface IOSSService { - public Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true); - public Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true); + public Task SetImmediateArchiveRule(string prefix, string ruleId = "immediate-archive", bool isDelete = false); + public Task RestoreFilesByPrefixAsync(string prefix, int restoreDays = 3, int batchSize = 100); + + + public Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true, FileUploadRecordAddOrEdit? uploadInfo = null); + public Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true, bool randomFileName = false, FileUploadRecordAddOrEdit? uploadInfo = null); public Task DownLoadFromOSSAsync(string ossRelativePath, string localFilePath); + public Task GetStreamFromOSSAsync(string ossRelativePath); + public ObjectStoreServiceOptions ObjectStoreServiceOptions { get; set; } public Task GetSignedUrl(string ossRelativePath); - public Task DeleteFromPrefix(string prefix); + public Task DeleteFromPrefix(string prefix, bool isCache = false); - public ObjectStoreDTO GetObjectStoreTempToken(); + public Task DeleteObjects(List objectKeys, bool isCache = false); + + List GetRootFolderNames(); + + public ObjectStoreDTO GetObjectStoreTempToken(string? domain = null, bool? isGetAllTempToken = null); + + public Task MoveObject(string sourcePath, string destPath, bool overwrite = true); + + public Task GetObjectSizeAsync(string sourcePath); + + public Task SyncFileAsync(string objectKey, ObjectStoreUse source, ObjectStoreUse destination, CancellationToken ct = default); } -public class OSSService : IOSSService +public class OSSService(IOptionsMonitor options, + IFileUploadRecordService _fileUploadRecordService) : IOSSService { - public ObjectStoreServiceOptions ObjectStoreServiceOptions { get; set; } + public ObjectStoreServiceOptions ObjectStoreServiceOptions { get; set; } = options.CurrentValue; private AliyunOSSTempToken AliyunOSSTempToken { get; set; } private AWSTempToken AWSTempToken { get; set; } - public OSSService(IOptionsMonitor options) - { - ObjectStoreServiceOptions = options.CurrentValue; + + /// + /// 将指定前缀下的所有现有文件立即转为目标存储类型 + /// + /// 要转换的文件前缀,如 "project-a/logs/" + /// 规则ID,默认为"immediate-archive" + /// 默认是添加/更新 + public async Task SetImmediateArchiveRule(string prefix, string ruleId = "immediate-archive", bool isDelete = false) + { + + BackBatchGetToken(); + + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + + + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + + try + { + // 1. 先获取现有的所有生命周期规则(避免覆盖) + var existingRules = new List(); + try + { + var existingRuleList = _ossClient.GetBucketLifecycle(aliConfig.BucketName); + if (existingRuleList != null) + { + existingRules.AddRange(existingRuleList); + Console.WriteLine($"找到 {existingRules.Count} 条现有规则"); + } + } + catch (OssException ex) when (ex.ErrorCode == "NoSuchLifecycle") + { + // 如果没有生命周期规则,继续创建新规则 + Console.WriteLine("当前Bucket无生命周期规则,将创建新规则"); + } + + // 2. 创建立即生效的转换规则 + + ruleId = $"{ruleId}_{prefix}"; + var immediateRule = new Aliyun.OSS.LifecycleRule + { + ID = ruleId, + Prefix = prefix, + Status = RuleStatus.Enabled, + Transitions = new Aliyun.OSS.LifecycleRule.LifeCycleTransition[] + { + new Aliyun.OSS.LifecycleRule.LifeCycleTransition + { + + LifeCycleExpiration = + { + Days = 1 + }, + StorageClass = StorageClass.IA + }, + new Aliyun.OSS.LifecycleRule.LifeCycleTransition + { + + LifeCycleExpiration = + { + Days = 30 //最后一次修改时间 + }, + StorageClass = StorageClass.Archive + } + } + }; + + + + + // 3. 移除同名的旧规则(如果存在) + existingRules.RemoveAll(r => r.ID == ruleId); + + // 4. 添加新规则到规则列表 + if (isDelete == false) + { + existingRules.Add(immediateRule); + + } + + + + var request = new SetBucketLifecycleRequest(aliConfig.BucketName) + { + LifecycleRules = existingRules + }; + + + _ossClient.SetBucketLifecycle(request); + + + } + catch (OssException ex) + { + Log.Logger.Error($"❌ 设置失败 [错误码: {ex.ErrorCode}] 详细: {ex.Message}"); + + } + catch (Exception ex) + { + Log.Logger.Error($"❌ 发生未知错误: {ex.Message}"); + } + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + // 1. 获取现有的生命周期配置(避免覆盖) + LifecycleConfiguration existingConfig = null; + + var getRequest = new GetLifecycleConfigurationRequest { BucketName = awsConfig.BucketName }; + var response = await amazonS3Client.GetLifecycleConfigurationAsync(getRequest); + existingConfig = response.Configuration; + Console.WriteLine($"找到 {existingConfig?.Rules?.Count ?? 0} 条现有规则"); + + // 2. 生成唯一的规则ID + ruleId = $"{ruleId}_{prefix.Replace('/', '_').Trim('_')}"; + + // 3. 创建新的生命周期规则 + var immediateRule = new Amazon.S3.Model.LifecycleRule + { + Id = ruleId, + Filter = new LifecycleFilter + { + // 使用前缀筛选对象 + LifecycleFilterPredicate = new LifecyclePrefixPredicate { Prefix = prefix } + }, + Status = LifecycleRuleStatus.Enabled, + // 定义多个转换阶段 + Transitions = new List + { + // 1天后转为低频访问 (Standard-IA) + //new LifecycleTransition + //{ + // Days = 1, //Days' in Transition action must be greater than or equal to 30 for storageClass 'STANDARD_IA'" + // StorageClass = S3StorageClass.StandardInfrequentAccess // 对应S3 Standard-IA + //}, + // 30天后转为归档 (Glacier Instant Retrieval) + new LifecycleTransition + { + Days = 30, //创建时间 + StorageClass = S3StorageClass.GlacierInstantRetrieval // 对应归档(即时检索) + } + // 如果需要更深的归档,可以继续添加: + // new LifecycleTransition { Days = 90, StorageClass = S3StorageClass.GlacierFlexibleRetrieval }, + // new LifecycleTransition { Days = 180, StorageClass = S3StorageClass.DeepArchive } + } + // 注意:S3的生命周期规则不支持设置“立即生效(Days=0)”。 + // 如果要对存量文件立即生效,需要配合其他方法(如批量修改存储类型)。 + }; + + // 4. 更新规则列表(移除同名旧规则,添加新规则) + var existingRules = existingConfig.Rules ?? new List(); + existingRules.RemoveAll(r => r.Id == ruleId); + + if (isDelete == false) + { + existingRules.Add(immediateRule); + + } + + // 5. 提交新的生命周期配置 + var putRequest = new PutLifecycleConfigurationRequest + { + BucketName = awsConfig.BucketName, + Configuration = new LifecycleConfiguration { Rules = existingRules } + }; + + await amazonS3Client.PutLifecycleConfigurationAsync(putRequest); + + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); + } } + /// 解冻指定前缀下的所有归档/冷归档文件 + /// + /// 要解冻的文件前缀 + /// 解冻后文件保持可读的天数(默认3天) + /// 解冻优先级(仅AWS有效) + /// 批量处理大小(默认100) + public async Task RestoreFilesByPrefixAsync(string prefix, int restoreDays = 3, int batchSize = 100) + { + BackBatchGetToken(); + + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + var client = new OssClient( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, + AliyunOSSTempToken.AccessKeyId, + AliyunOSSTempToken.AccessKeySecret, + AliyunOSSTempToken.SecurityToken + ); + + var bucketName = aliConfig.BucketName; + int totalRestored = 0; + int totalSkipped = 0; + int totalFailed = 0; + + try + { + Console.WriteLine($"开始解冻阿里云OSS文件,前缀: {prefix}"); + + var allObjects = new List(); + + // 1. 分页列举文件 + string nextMarker = null; + ObjectListing result = null; + + do + { + var listRequest = new Aliyun.OSS.ListObjectsRequest(bucketName) + { + Prefix = prefix, + Marker = nextMarker, + MaxKeys = batchSize + }; + + result = client.ListObjects(listRequest); + + allObjects.AddRange(result.ObjectSummaries); + + + + nextMarker = result.NextMarker; + + } while (result.IsTruncated); + + // 2️⃣ 并行解冻(控制并发) + Parallel.ForEach( + allObjects, + new ParallelOptions + { + MaxDegreeOfParallelism = 5 // ⭐ 推荐 5~10 + }, + obj => + { + // 只处理归档 + if (obj.StorageClass != StorageClass.Archive.ToString()) + { + Interlocked.Increment(ref totalSkipped); + return; + } + + try + { + var restoreRequest = new Aliyun.OSS.RestoreObjectRequest(bucketName, obj.Key) + { + Days = restoreDays + }; + + client.RestoreObject(restoreRequest); + + Interlocked.Increment(ref totalRestored); + Console.WriteLine($"✅ 提交解冻: {obj.Key}"); + } + catch (OssException ex) when (ex.ErrorCode == "RestoreAlreadyInProgress") + { + // 已在解冻中,算成功 + Interlocked.Increment(ref totalSkipped); + Console.WriteLine($"⚠️ 已在解冻中: {obj.Key}"); + } + catch (Exception ex) + { + Interlocked.Increment(ref totalFailed); + Console.WriteLine($"❌ 解冻失败: {obj.Key} - {ex.Message}"); + } + } + ); + + // 3. 输出统计结果 + Console.WriteLine("\n================ 解冻完成 ================"); + Console.WriteLine($"总计处理: {totalRestored + totalSkipped + totalFailed} 个文件"); + Console.WriteLine($"成功解冻: {totalRestored} 个"); + Console.WriteLine($"跳过文件: {totalSkipped} 个 (非归档类型)"); + Console.WriteLine($"解冻失败: {totalFailed} 个"); + + if (totalRestored > 0) + { + Console.WriteLine($"\n📋 解冻说明:"); + Console.WriteLine($" • 解冻任务已提交,文件将在后台处理"); + Console.WriteLine($" • 解冻完成后,文件将保持可读状态 {restoreDays} 天"); + Console.WriteLine($" • 归档文件约需1分钟,冷归档需数小时"); + } + } + catch (Exception ex) + { + Log.Logger.Error($"❌ 阿里云解冻操作失败: {ex.Message}"); + throw; + } + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + var awsConfig = ObjectStoreServiceOptions.AWS; + var credentials = new SessionAWSCredentials( + AWSTempToken.AccessKeyId, + AWSTempToken.SecretAccessKey, + AWSTempToken.SessionToken + ); + + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region), + UseHttp = true, + }; + + using var client = new AmazonS3Client(credentials, clientConfig); + + var bucketName = awsConfig.BucketName; + int totalRestored = 0; + int totalSkipped = 0; + int totalFailed = 0; + + try + { + Console.WriteLine($"开始解冻AWS S3文件,前缀: {prefix}"); + + var allObjects = new List(); + + // 1. 分页列举文件 + string continuationToken = null; + ListObjectsV2Response response = null; + + do + { + var listRequest = new ListObjectsV2Request + { + BucketName = bucketName, + Prefix = prefix, + ContinuationToken = continuationToken, + MaxKeys = batchSize + }; + + response = await client.ListObjectsV2Async(listRequest); + + allObjects.AddRange(response.S3Objects); + + + continuationToken = response.NextContinuationToken; + + } while (response.IsTruncated == true); + + // 2️⃣ 并行解冻(控制并发) + await Parallel.ForEachAsync( + allObjects, + new ParallelOptions + { + MaxDegreeOfParallelism = 5 // ⭐ 推荐 5~10 + }, + async (obj, ct) => + { + // 只处理归档 + if (obj.StorageClass != S3StorageClass.Glacier) + { + Interlocked.Increment(ref totalSkipped); + return; + } + + try + { + var restoreRequest = new Amazon.S3.Model.RestoreObjectRequest + { + BucketName = bucketName, + Key = obj.Key, + Days = restoreDays, + + }; + + await client.RestoreObjectAsync(restoreRequest); + + Interlocked.Increment(ref totalRestored); + Console.WriteLine($"✅ 提交解冻: {obj.Key}"); + } + catch (OssException ex) when (ex.ErrorCode == "RestoreAlreadyInProgress") + { + // 已在解冻中,算成功 + Interlocked.Increment(ref totalSkipped); + Console.WriteLine($"⚠️ 已在解冻中: {obj.Key}"); + } + catch (Exception ex) + { + Interlocked.Increment(ref totalFailed); + Console.WriteLine($"❌ 解冻失败: {obj.Key} - {ex.Message}"); + } + } + ); + + // 3. 输出统计结果 + Console.WriteLine("\n================ 解冻完成 ================"); + Console.WriteLine($"总计处理: {totalRestored + totalSkipped + totalFailed} 个文件"); + Console.WriteLine($"成功解冻: {totalRestored} 个"); + Console.WriteLine($"跳过文件: {totalSkipped} 个 (非归档类型)"); + Console.WriteLine($"解冻失败: {totalFailed} 个"); + + if (totalRestored > 0) + { + Console.WriteLine($"\n📋 AWS解冻说明:"); + Console.WriteLine($" • 解冻任务已提交到Glacier服务"); + Console.WriteLine($" • 标准解冻: 3-5小时 (Glacier Flexible Retrieval)"); + Console.WriteLine($" • 加急解冻: 1-5分钟 (额外收费)"); + Console.WriteLine($" • 解冻后文件可读 {restoreDays} 天"); + } + } + catch (Exception ex) + { + Log.Logger.Error($"❌ AWS解冻操作失败: {ex.Message}"); + throw; + } + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); + } + } + + + /// + /// 坑方法,会清空之前的规则 + /// + /// + /// + /// + /// + public async Task SetLifecycle(string prefix, string ruleId = "immediate-archive") + { + BackBatchGetToken(); + + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + + ruleId = $"{ruleId}_{prefix}"; + var rule = new Aliyun.OSS.LifecycleRule + { + ID = ruleId, + Prefix = prefix, + Status = RuleStatus.Enabled, + Transitions = new Aliyun.OSS.LifecycleRule.LifeCycleTransition[] + { + new Aliyun.OSS.LifecycleRule.LifeCycleTransition + { + + LifeCycleExpiration = + { + Days = 1 + }, + StorageClass = StorageClass.IA + }, + new Aliyun.OSS.LifecycleRule.LifeCycleTransition + { + + LifeCycleExpiration = + { + Days = 30 + }, + StorageClass = StorageClass.Archive + } + } + }; + + + //会清空之前历史的规则,不能用。。。 + var request = new SetBucketLifecycleRequest(aliConfig.BucketName); + request.AddLifecycleRule(rule); + + _ossClient.SetBucketLifecycle(request); + + + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.USEast1, + UseHttp = true, + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); + } + } + + /// @@ -173,83 +739,78 @@ public class OSSService : IOSSService /// /// /// + /// 只用赋值业务参数Id 和批次信息即可,其他信息不用传递 /// - public async Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true) + public async Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true, FileUploadRecordAddOrEdit? uploadInfo = null) { - GetObjectStoreTempToken(); + BackBatchGetToken(); var ossRelativePath = isFileNameAddGuid ? $"{oosFolderPath}/{Guid.NewGuid()}_{fileRealName}" : $"{oosFolderPath}/{fileRealName}"; try { - using (var memoryStream = new MemoryStream()) - { + if (fileStream.CanSeek) fileStream.Seek(0, SeekOrigin.Begin); - fileStream.CopyTo(memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); - if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + + // 上传文件 + var result = _ossClient.PutObject(aliConfig.BucketName, ossRelativePath, fileStream); + + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") + { + var minIOConfig = ObjectStoreServiceOptions.MinIO; + + + var minioClient = new MinioClient().WithEndpoint($"{minIOConfig.EndPoint}:{minIOConfig.Port}") + .WithCredentials(minIOConfig.AccessKeyId, minIOConfig.SecretAccessKey).WithSSL(minIOConfig.UseSSL) + .Build(); + + var putObjectArgs = new PutObjectArgs() + .WithBucket(minIOConfig.BucketName) + .WithObject(ossRelativePath) + .WithStreamData(fileStream) + .WithObjectSize(fileStream.Length); + + await minioClient.PutObjectAsync(putObjectArgs); + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config { - var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, + }; - var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); - - - // 上传文件 - var result = _ossClient.PutObject(aliConfig.BucketName, ossRelativePath, memoryStream); - - } - else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") + var putObjectRequest = new Amazon.S3.Model.PutObjectRequest() { - var minIOConfig = ObjectStoreServiceOptions.MinIO; + BucketName = awsConfig.BucketName, + InputStream = fileStream, + Key = ossRelativePath, + }; - - var minioClient = new MinioClient().WithEndpoint($"{minIOConfig.EndPoint}:{minIOConfig.Port}") - .WithCredentials(minIOConfig.AccessKeyId, minIOConfig.SecretAccessKey).WithSSL(minIOConfig.UseSSL) - .Build(); - - var putObjectArgs = new PutObjectArgs() - .WithBucket(minIOConfig.BucketName) - .WithObject(ossRelativePath) - .WithStreamData(memoryStream) - .WithObjectSize(memoryStream.Length); - - await minioClient.PutObjectAsync(putObjectArgs); - } - else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") - { - var awsConfig = ObjectStoreServiceOptions.AWS; - - var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); - - - - //提供awsEndPoint(域名)进行访问配置 - var clientConfig = new AmazonS3Config - { - RegionEndpoint = RegionEndpoint.USEast1, - UseHttp = true, - }; - - var amazonS3Client = new AmazonS3Client(credentials, clientConfig); - - var putObjectRequest = new Amazon.S3.Model.PutObjectRequest() - { - BucketName = awsConfig.BucketName, - InputStream = memoryStream, - Key = ossRelativePath, - }; - - await amazonS3Client.PutObjectAsync(putObjectRequest); - } - else - { - throw new BusinessValidationFailedException("未定义的存储介质类型"); - } + await amazonS3Client.PutObjectAsync(putObjectRequest); + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); } } catch (Exception ex) @@ -259,30 +820,83 @@ public class OSSService : IOSSService } + var returnPath = "/" + ossRelativePath; - return "/" + ossRelativePath; + if (ObjectStoreServiceOptions.IsOpenStoreSync && uploadInfo != null) + { + uploadInfo.FileSize = fileStream.CanSeek ? fileStream.Length : 0; + uploadInfo.Path = returnPath; + uploadInfo.FileName = fileRealName; + uploadInfo.FileType = Path.GetExtension(returnPath); + + + await _fileUploadRecordService.AddOrUpdateFileUploadRecord(uploadInfo); + } + + + return returnPath; } + //后端批量上传 或者下载,不每个文件获取临时token + private void BackBatchGetToken() + { + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + if (AliyunOSSTempToken == null) + { + GetObjectStoreTempToken(); + } + //token 过期了 + if (AliyunOSSTempToken?.Expiration.AddSeconds(10) <= DateTime.Now) + { + GetObjectStoreTempToken(); + } + + + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + if (AWSTempToken == null) + { + GetObjectStoreTempToken(); + } + //token 过期了 + if (AWSTempToken.Expiration?.AddSeconds(10) <= DateTime.Now) + { + GetObjectStoreTempToken(); + } + } + } + + /// /// oosFolderPath 不要 "/ "开头 应该: TempFolder/ChildFolder /// /// /// /// + /// 随机文件名 /// /// - public async Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true) + public async Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true, bool randomFileName = false, FileUploadRecordAddOrEdit? uploadInfo = null) { - GetObjectStoreTempToken(); + BackBatchGetToken(); + + long fileSize = 0; var localFileName = Path.GetFileName(localFilePath); var ossRelativePath = isFileNameAddGuid ? $"{oosFolderPath}/{Guid.NewGuid()}_{localFileName}" : $"{oosFolderPath}/{localFileName}"; + if (randomFileName) + { + var fileExtension = localFileName.Split(".").LastOrDefault(); + ossRelativePath = $"{oosFolderPath}/{Guid.NewGuid()}.{fileExtension}"; + } if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") { @@ -293,6 +907,8 @@ public class OSSService : IOSSService // 上传文件 var result = _ossClient.PutObject(aliConfig.BucketName, ossRelativePath, localFilePath); + fileSize = result.ContentLength; + } else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") { @@ -308,7 +924,9 @@ public class OSSService : IOSSService .WithObject(ossRelativePath) .WithFileName(localFilePath); - await minioClient.PutObjectAsync(putObjectArgs); + var result = await minioClient.PutObjectAsync(putObjectArgs); + + fileSize = result.Size; } else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") { @@ -320,8 +938,8 @@ public class OSSService : IOSSService //提供awsEndPoint(域名)进行访问配置 var clientConfig = new AmazonS3Config { - RegionEndpoint = RegionEndpoint.USEast1, - UseHttp = true, + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, }; var amazonS3Client = new AmazonS3Client(credentials, clientConfig); @@ -333,21 +951,37 @@ public class OSSService : IOSSService Key = ossRelativePath, }; - await amazonS3Client.PutObjectAsync(putObjectRequest); - + var result = await amazonS3Client.PutObjectAsync(putObjectRequest); + fileSize = result.ContentLength; } else { throw new BusinessValidationFailedException("未定义的存储介质类型"); } - return "/" + ossRelativePath; + + var returnPath = "/" + ossRelativePath; + + + if (ObjectStoreServiceOptions.IsOpenStoreSync && uploadInfo != null) + { + uploadInfo.FileSize = fileSize; + uploadInfo.Path = returnPath; + uploadInfo.FileName = Path.GetFileName(localFilePath); + uploadInfo.FileType = Path.GetExtension(returnPath); + + + await _fileUploadRecordService.AddOrUpdateFileUploadRecord(uploadInfo); + } + + + return returnPath; } public async Task DownLoadFromOSSAsync(string ossRelativePath, string localFilePath) { - GetObjectStoreTempToken(); + BackBatchGetToken(); ossRelativePath = ossRelativePath.TrimStart('/'); try @@ -360,14 +994,12 @@ public class OSSService : IOSSService var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); - // 上传文件 var result = _ossClient.GetObject(aliConfig.BucketName, ossRelativePath); // 将下载的文件流保存到本地文件 using (var fs = File.OpenWrite(localFilePath)) { - result.Content.CopyTo(fs); - fs.Close(); + await result.Content.CopyToAsync(fs); } } @@ -397,8 +1029,8 @@ public class OSSService : IOSSService //提供awsEndPoint(域名)进行访问配置 var clientConfig = new AmazonS3Config { - RegionEndpoint = RegionEndpoint.USEast1, - UseHttp = true, + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, }; var amazonS3Client = new AmazonS3Client(credentials, clientConfig); @@ -431,6 +1063,107 @@ public class OSSService : IOSSService } + public async Task GetStreamFromOSSAsync(string ossRelativePath) + { + BackBatchGetToken(); + ossRelativePath = ossRelativePath.TrimStart('/'); + + try + { + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, + AliyunOSSTempToken.AccessKeyId, + AliyunOSSTempToken.AccessKeySecret, + AliyunOSSTempToken.SecurityToken + ); + + var result = _ossClient.GetObject(aliConfig.BucketName, ossRelativePath); + + // 直接返回流 + return result.Content; + + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") + { + var minIOConfig = ObjectStoreServiceOptions.MinIO; + + var minioClient = new MinioClient() + .WithEndpoint($"{minIOConfig.EndPoint}:{minIOConfig.Port}") + .WithCredentials(minIOConfig.AccessKeyId, minIOConfig.SecretAccessKey) + .WithSSL(minIOConfig.UseSSL) + .Build(); + + var pipe = new System.IO.Pipelines.Pipe(); + + _ = 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(); + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials( + AWSTempToken.AccessKeyId, + AWSTempToken.SecretAccessKey, + AWSTempToken.SessionToken + ); + + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.USEast1, + UseHttp = true, + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + var getObjectRequest = new Amazon.S3.Model.GetObjectRequest + { + BucketName = awsConfig.BucketName, + Key = ossRelativePath + }; + + var response = await amazonS3Client.GetObjectAsync(getObjectRequest); + + // ⭐ 直接返回流 + return response.ResponseStream; + + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); + } + } + catch (Exception ex) + { + throw new BusinessValidationFailedException("oss流获取失败! " + ex.Message); + } + } + + public async Task GetSignedUrl(string ossRelativePath) { GetObjectStoreTempToken(); @@ -493,8 +1226,8 @@ public class OSSService : IOSSService //提供awsEndPoint(域名)进行访问配置 var clientConfig = new AmazonS3Config { - RegionEndpoint = RegionEndpoint.USEast1, - UseHttp = true, + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, }; var amazonS3Client = new AmazonS3Client(credentials, clientConfig); @@ -526,21 +1259,287 @@ public class OSSService : IOSSService } + /// - /// 删除某个目录的文件 + /// 移动OSS文件到新路径 /// - /// - /// - public async Task DeleteFromPrefix(string prefix) + /// 原文件路径(格式:bucket/key) + /// 新文件路径(格式:bucket/key) + /// 是否覆盖已存在的目标文件(默认true) + public async Task MoveObject(string sourcePath, string destPath, bool overwrite = true) { GetObjectStoreTempToken(); + switch (ObjectStoreServiceOptions.ObjectStoreUse) + { + case "AliyunOSS": + #region 阿里云 + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + var client = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + if (sourcePath.StartsWith("/")) + { + sourcePath = sourcePath.Substring(1); + } + + + if (destPath.StartsWith("/")) + { + destPath = destPath.Substring(1); + } + + var sourceBucket = aliConfig.BucketName; + var sourceKey = sourcePath; + + var destBucket = aliConfig.BucketName; + + var destKey = destPath; + + + try + { + // 检查目标是否存在(当不允许覆盖时) + if (!overwrite && client.DoesObjectExist(destBucket, destKey)) + { + throw new InvalidOperationException("File Exist"); + } + + + //var copyRequest = new Aliyun.OSS.CopyObjectRequest(sourceBucket, sourceKey, sourceBucket, destKey); + //var result = client.CopyObject(copyRequest); + + //// 2. 删除原文件(可选,根据是否需要保留原文件) + //client.DeleteObject(sourceBucket, sourceKey); + + // 执行复制 + var copyRequestAli = new Aliyun.OSS.CopyObjectRequest( + sourceBucket, sourceKey, + destBucket, destKey); + + // 保持原文件元数据 + copyRequestAli.NewObjectMetadata = new ObjectMetadata + { + ContentType = client.GetObjectMetadata(sourceBucket, sourceKey).ContentType + }; + + var result = client.CopyObject(copyRequestAli); + + // 删除原文件(仅在复制成功后) + client.DeleteObject(sourceBucket, sourceKey); + } + catch (OssException ex) + { + throw new Exception($"[{ex.ErrorCode}] {ex.Message}", ex); + } + #endregion + + break; + case "MinIO": + #region MinIO + var minIOConfig = ObjectStoreServiceOptions.MinIO; + var minioClient = new MinioClient() + .WithEndpoint($"{minIOConfig.EndPoint}:{minIOConfig.Port}") + .WithCredentials(minIOConfig.AccessKeyId, minIOConfig.SecretAccessKey) + .WithSSL(minIOConfig.UseSSL) + .Build(); + + // 定义源路径和目标路径 + string destinationKey = "b路径/文件名"; + + try + { + // 1. 复制文件到新路径[2,5](@ref) + using (var memoryStream = new MemoryStream()) + { + // 下载源文件流 + await minioClient.GetObjectAsync(new GetObjectArgs() + .WithBucket(minIOConfig.BucketName) + .WithObject(sourcePath) + .WithCallbackStream(stream => stream.CopyTo(memoryStream))); + + memoryStream.Position = 0; // 重置流位置 + + // 上传到新路径 + await minioClient.PutObjectAsync(new PutObjectArgs() + .WithBucket(minIOConfig.BucketName) + .WithObject(destinationKey) + .WithStreamData(memoryStream) + .WithObjectSize(memoryStream.Length)); + } + + // 2. 删除原文件[1,6](@ref) + await minioClient.RemoveObjectAsync(new RemoveObjectArgs() + .WithBucket(minIOConfig.BucketName) + .WithObject(sourcePath)); + } + catch (MinioException ex) + { + // 处理异常(例如:记录日志或抛出) + throw new Exception(); + } + #endregion + + break; + case "AWS": + #region AWS + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials( + AWSTempToken.AccessKeyId, + AWSTempToken.SecretAccessKey, + AWSTempToken.SessionToken + ); + + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.USEast1, + UseHttp = true, + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + // 定义原路径和目标路径 + + + // 1. 复制对象到新路径 + var copyRequest = new Amazon.S3.Model.CopyObjectRequest + { + SourceBucket = awsConfig.BucketName, + SourceKey = sourcePath, + DestinationBucket = awsConfig.BucketName, + DestinationKey = destPath + }; + + try + { + // 执行复制操作 + await amazonS3Client.CopyObjectAsync(copyRequest); + + // 2. 删除原对象 + var deleteRequest = new Amazon.S3.Model.DeleteObjectRequest + { + BucketName = awsConfig.BucketName, + Key = sourcePath + }; + await amazonS3Client.DeleteObjectAsync(deleteRequest); + + + } + catch (AmazonS3Exception ex) + { + Console.WriteLine($"ERROR: {ex.Message}"); + // 可根据异常类型细化处理(如文件不存在、权限问题等) + } + #endregion + break; + default: + throw new BusinessValidationFailedException("ERROR"); + + } + + } + + /// + /// 获取所有根目录名称 + /// + /// + public List GetRootFolderNames() + { + GetObjectStoreTempToken(); + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, + AliyunOSSTempToken.AccessKeyId, + AliyunOSSTempToken.AccessKeySecret, + AliyunOSSTempToken.SecurityToken); + + List rootFolders = new List(); + string nextMarker = null; + + try + { + ObjectListing objectListing = null; + do + { + // 列出根目录下的对象和文件夹 + objectListing = _ossClient.ListObjects(new Aliyun.OSS.ListObjectsRequest(aliConfig.BucketName) + { + + MaxKeys = 1000, + Marker = nextMarker, + Delimiter = "/" // 使用分隔符来模拟文件夹 + }); + + // 遍历 CommonPrefixes 获取根文件夹名称 + foreach (var prefix in objectListing.CommonPrefixes) + { + rootFolders.Add(prefix.TrimEnd('/')); // 去掉末尾的斜杠 + } + + // 设置 NextMarker 以获取下一页的数据 + nextMarker = objectListing.NextMarker; + + } while (objectListing.IsTruncated); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + return rootFolders; + } + + /// + /// 删除某个目录的文件 (包含单个文件,oss单个文件需要去除前缀/) + /// + /// + /// + public async Task DeleteFromPrefix(string prefix, bool isCache = false) + { + + //打开了同步的,删除的时候,一起删除 + if (ObjectStoreServiceOptions.IsOpenStoreSync && ObjectStoreServiceOptions.SyncConfigList.Any(t => t.IsOpenSync)) + { + foreach (var config in ObjectStoreServiceOptions.SyncConfigList.Where(t => t.IsOpenSync)) + { + ObjectStoreServiceOptions.ObjectStoreUse = config.Primary; + + GetObjectStoreTempToken(); + + await DeleteFromPrefixInternal(prefix, isCache); + } + } + else + { + GetObjectStoreTempToken(); + + await DeleteFromPrefixInternal(prefix, isCache); + } + + } + + + private async Task DeleteFromPrefixInternal(string prefix, bool isCache = false) + { if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") { var aliConfig = ObjectStoreServiceOptions.AliyunOSS; var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + var bucketName = string.Empty; + + if (isCache) + { + Uri uri = new Uri(aliConfig.ViewEndpoint); + string host = uri.Host; // 获取 "zy-irc-test-dev-cache.oss-cn-shanghai.aliyuncs.com" + string[] parts = host.Split('.'); + bucketName = parts[0]; + } + else + { + bucketName = aliConfig.BucketName; + } + + try { @@ -549,7 +1548,7 @@ public class OSSService : IOSSService do { // 使用 prefix 模拟目录结构,设置 MaxKeys 和 NextMarker - objectListing = _ossClient.ListObjects(new Aliyun.OSS.ListObjectsRequest(aliConfig.BucketName) + objectListing = _ossClient.ListObjects(new Aliyun.OSS.ListObjectsRequest(bucketName) { Prefix = prefix, MaxKeys = 1000, @@ -561,7 +1560,7 @@ public class OSSService : IOSSService // 删除获取到的文件 if (keys.Count > 0) { - _ossClient.DeleteObjects(new Aliyun.OSS.DeleteObjectsRequest(aliConfig.BucketName, keys, false)); + _ossClient.DeleteObjects(new Aliyun.OSS.DeleteObjectsRequest(bucketName, keys, false)); } // 设置 NextMarker 以获取下一页的数据 @@ -618,15 +1617,14 @@ public class OSSService : IOSSService var awsConfig = ObjectStoreServiceOptions.AWS; - // 提供awsAccessKeyId和awsSecretAccessKey构造凭证 var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); //提供awsEndPoint(域名)进行访问配置 var clientConfig = new AmazonS3Config { - RegionEndpoint = RegionEndpoint.USEast1, - UseHttp = true, + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, }; var amazonS3Client = new AmazonS3Client(credentials, clientConfig); @@ -640,7 +1638,7 @@ public class OSSService : IOSSService var listObjectsResponse = await amazonS3Client.ListObjectsV2Async(listObjectsRequest); - if (listObjectsResponse.S3Objects.Count > 0) + if (listObjectsResponse.S3Objects?.Count > 0) { // 准备删除请求 var deleteObjectsRequest = new Amazon.S3.Model.DeleteObjectsRequest @@ -671,13 +1669,213 @@ public class OSSService : IOSSService } - public ObjectStoreDTO GetObjectStoreTempToken() + public async Task DeleteObjects(List objectKeys, bool isCache = false) + { + //打开了同步的,删除的时候,一起删除 + if (ObjectStoreServiceOptions.IsOpenStoreSync && ObjectStoreServiceOptions.SyncConfigList.Any(t => t.IsOpenSync)) + { + foreach (var config in ObjectStoreServiceOptions.SyncConfigList.Where(t => t.IsOpenSync)) + { + ObjectStoreServiceOptions.ObjectStoreUse = config.Primary; + + GetObjectStoreTempToken(); + + await DeleteObjectsInternal(objectKeys, isCache); + } + } + else + { + GetObjectStoreTempToken(); + + await DeleteObjectsInternal(objectKeys, isCache); + } + } + public async Task DeleteObjectsInternal(List objectKeys, bool isCache = false) { - var ossOptions = ObjectStoreServiceOptions.AliyunOSS; + + GetObjectStoreTempToken(); if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") { + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + + var bucketName = string.Empty; + + if (isCache) + { + Uri uri = new Uri(aliConfig.ViewEndpoint); + string host = uri.Host; // 获取 "zy-irc-test-dev-cache.oss-cn-shanghai.aliyuncs.com" + string[] parts = host.Split('.'); + bucketName = parts[0]; + } + else + { + bucketName = aliConfig.BucketName; + } + + if (objectKeys.Count > 0) + { + var result = _ossClient.DeleteObjects(new Aliyun.OSS.DeleteObjectsRequest(bucketName, objectKeys, false)); + + } + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") + { + var minIOConfig = ObjectStoreServiceOptions.MinIO; + + + var minioClient = new MinioClient().WithEndpoint($"{minIOConfig.EndPoint}:{minIOConfig.Port}") + .WithCredentials(minIOConfig.AccessKeyId, minIOConfig.SecretAccessKey).WithSSL(minIOConfig.UseSSL) + .Build(); + + + if (objectKeys.Count > 0) + { + var objArgs = new RemoveObjectsArgs() + .WithBucket(minIOConfig.BucketName) + .WithObjects(objectKeys); + + // 删除对象 + await minioClient.RemoveObjectsAsync(objArgs); + } + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + + var awsConfig = ObjectStoreServiceOptions.AWS; + + + // 提供awsAccessKeyId和awsSecretAccessKey构造凭证 + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + if (objectKeys.Count > 0) + { + // 准备删除请求 + var deleteObjectsRequest = new Amazon.S3.Model.DeleteObjectsRequest + { + BucketName = awsConfig.BucketName, + Objects = objectKeys.Select(t => new KeyVersion() { Key = t }).ToList() + }; + + + // 批量删除对象 + var deleteObjectsResponse = await amazonS3Client.DeleteObjectsAsync(deleteObjectsRequest); + } + + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); + } + } + + public async Task GetObjectSizeAsync(string sourcePath) + { + BackBatchGetToken(); + + + var objectkey = sourcePath.Trim('/'); + + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + { + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + + var key = HttpUtility.UrlDecode(objectkey); + var metadata = _ossClient.GetObjectMetadata(aliConfig.BucketName, key); + + long fileSize = metadata?.ContentLength ?? 0; // 文件大小(字节) + + return fileSize; + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") + { + var minIOConfig = ObjectStoreServiceOptions.MinIO; + + + var minioClient = new MinioClient().WithEndpoint($"{minIOConfig.EndPoint}:{minIOConfig.Port}") + .WithCredentials(minIOConfig.AccessKeyId, minIOConfig.SecretAccessKey).WithSSL(minIOConfig.UseSSL) + .Build(); + + + var stat = await minioClient.StatObjectAsync(new Minio.DataModel.Args.StatObjectArgs() + .WithBucket(minIOConfig.BucketName) + .WithObject(objectkey)); + + return stat.Size; // 文件大小(字节) + } + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + { + + var awsConfig = ObjectStoreServiceOptions.AWS; + + + // 提供awsAccessKeyId和awsSecretAccessKey构造凭证 + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + //,UseHttp = true, + }; + + var request = new Amazon.S3.Model.GetObjectMetadataRequest + { + BucketName = awsConfig.BucketName, + Key = objectkey + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + var response = await amazonS3Client.GetObjectMetadataAsync(request); + + long fileSize = response.ContentLength; // 文件大小(字节) + + return fileSize; + + } + else + { + throw new BusinessValidationFailedException("未定义的存储介质类型"); + } + } + + + + public ObjectStoreDTO GetObjectStoreTempToken(string? domain = null, bool? isGetAllTempToken = null) + { + //如果传递了域名,并且打开了存储同步,根据域名使用的具体存储覆盖之前的配置,否则就用固定的配置 + if (ObjectStoreServiceOptions.IsOpenStoreSync && domain.IsNotNullOrEmpty()) + { + var userDomain = domain?.Trim(); + + var find = ObjectStoreServiceOptions.SyncConfigList.FirstOrDefault(t => t.Domain == userDomain); + if (find != null) + { + ObjectStoreServiceOptions.ObjectStoreUse = find.Primary; + } + } + + var objectStoreDTO = new ObjectStoreDTO() { ObjectStoreUse = ObjectStoreServiceOptions.ObjectStoreUse, IsOpenStoreSync = ObjectStoreServiceOptions.IsOpenStoreSync, SyncConfigList = ObjectStoreServiceOptions.SyncConfigList }; + + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS" || isGetAllTempToken == true) + { + var ossOptions = ObjectStoreServiceOptions.AliyunOSS; + var client = new Client(new AlibabaCloud.OpenApiClient.Models.Config() { AccessKeyId = ossOptions.AccessKeyId, @@ -713,24 +1911,33 @@ public class OSSService : IOSSService BucketName = ossOptions.BucketName, EndPoint = ossOptions.EndPoint, ViewEndpoint = ossOptions.ViewEndpoint, + PreviewEndpoint = ossOptions.PreviewEndpoint }; AliyunOSSTempToken = tempToken; - return new ObjectStoreDTO() { ObjectStoreUse = ObjectStoreServiceOptions.ObjectStoreUse, AliyunOSS = tempToken }; + objectStoreDTO.AliyunOSS = tempToken; + } else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") { - return new ObjectStoreDTO() { ObjectStoreUse = ObjectStoreServiceOptions.ObjectStoreUse, MinIO = ObjectStoreServiceOptions.MinIO }; + objectStoreDTO.MinIO = ObjectStoreServiceOptions.MinIO; } - else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS" || isGetAllTempToken == true) { var awsOptions = ObjectStoreServiceOptions.AWS; + + // 创建 STS 客户端(考虑使用 RegionEndpoint) + var stsConfig = new AmazonSecurityTokenServiceConfig + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsOptions.Region) + }; + //aws 临时凭证 // 创建 STS 客户端 - var stsClient = new AmazonSecurityTokenServiceClient(awsOptions.AccessKeyId, awsOptions.SecretAccessKey); + var stsClient = new AmazonSecurityTokenServiceClient(awsOptions.AccessKeyId, awsOptions.SecretAccessKey, stsConfig); // 使用 AssumeRole 请求临时凭证 var assumeRoleRequest = new AssumeRoleRequest @@ -759,12 +1966,69 @@ public class OSSService : IOSSService }; AWSTempToken = tempToken; - return new ObjectStoreDTO() { ObjectStoreUse = ObjectStoreServiceOptions.ObjectStoreUse, AWS = tempToken }; + + objectStoreDTO.AWS = tempToken; } else { throw new BusinessValidationFailedException("未定义的存储介质类型"); } + + return objectStoreDTO; + } + + + public async Task SyncFileAsync(string objectKey, ObjectStoreUse source, ObjectStoreUse destination, CancellationToken ct = default) + { + GetObjectStoreTempToken(isGetAllTempToken: true); + + + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + + // 根据源选择流式下载 + Stream sourceStream = source switch + { + ObjectStoreUse.AliyunOSS => _ossClient.GetObject(aliConfig.BucketName, objectKey).Content, + ObjectStoreUse.AWS => (await amazonS3Client.GetObjectAsync(awsConfig.BucketName, objectKey, ct)).ResponseStream, + _ => throw new BusinessValidationFailedException("未定义的同步类型") + }; + + if (source == ObjectStoreUse.AliyunOSS) + { + var putRequest = new Amazon.S3.Model.PutObjectRequest + { + BucketName = awsConfig.BucketName, + Key = objectKey, + InputStream = sourceStream + }; + + await amazonS3Client.PutObjectAsync(putRequest, ct); + } + else if (source == ObjectStoreUse.AWS) + { + _ossClient.PutObject(aliConfig.BucketName, objectKey, sourceStream); + } + else + { + throw new BusinessValidationFailedException("未定义的同步类型"); + } + + await sourceStream.DisposeAsync(); // 释放流 } } diff --git a/IRaCIS.Core.API/Controllers/ExtraController.cs b/IRaCIS.Core.API/Controllers/ExtraController.cs index cf591fc64..d6fabd6c8 100644 --- a/IRaCIS.Core.API/Controllers/ExtraController.cs +++ b/IRaCIS.Core.API/Controllers/ExtraController.cs @@ -143,7 +143,7 @@ namespace IRaCIS.Api.Controllers var result = _oSSService.GetObjectStoreTempToken(domain); - //result.AWS = await GetAWSTemToken(options.CurrentValue); + Log.Logger.Information($"使用域名:{domain}请求token.返回{result.ToJsonStr()}"); diff --git a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs index 8327b9e93..45fe63600 100644 --- a/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs +++ b/IRaCIS.Core.API/Controllers/UploadDownLoadController.cs @@ -9,6 +9,7 @@ using IRaCIS.Core.Application.Filter; using IRaCIS.Core.Application.Helper; using IRaCIS.Core.Application.MassTransit.Command; using IRaCIS.Core.Application.Service; +using IRaCIS.Core.Application.ViewModel; using IRaCIS.Core.Domain.Models; using IRaCIS.Core.Domain.Share; using IRaCIS.Core.Infra.EFCore; @@ -598,7 +599,7 @@ namespace IRaCIS.Core.API.Controllers templateFileStream.Seek(0, SeekOrigin.Begin); - var ossRelativePath = await oSSService.UploadToOSSAsync(fileStream, $"{trialId.ToString()}/InspectionUpload/DataReconciliation", realFileName); + var ossRelativePath = await oSSService.UploadToOSSAsync(fileStream, $"{trialId.ToString()}/InspectionUpload/DataReconciliation", realFileName, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialId, BatchDataType = BatchDataType.DataReconciliation }); var addEntity = await _inspectionFileRepository.AddAsync(new InspectionFile() { FileName = realFileName, RelativePath = ossRelativePath, TrialId = trialId }, true); @@ -856,7 +857,7 @@ namespace IRaCIS.Core.API.Controllers throw new BusinessValidationFailedException(_localizer["UploadDownLoad_TemplateUploadData"]); } - var ossRelativePath = await oSSService.UploadToOSSAsync(fileStream, $"{trialId.ToString()}/InspectionUpload/SiteSurvey", realFileName); + var ossRelativePath = await oSSService.UploadToOSSAsync(fileStream, $"{trialId.ToString()}/InspectionUpload/SiteSurvey", realFileName, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialId ,BatchDataType=BatchDataType.SiteUserSurvey}); await _inspectionFileRepository.AddAsync(new InspectionFile() { FileName = realFileName, RelativePath = ossRelativePath, TrialId = trialId }, true); @@ -1008,7 +1009,7 @@ namespace IRaCIS.Core.API.Controllers Other = 6, - + } /// diff --git a/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs b/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs new file mode 100644 index 000000000..d1abb9ddd --- /dev/null +++ b/IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs @@ -0,0 +1,134 @@ +using IRaCIS.Core.Application.Helper; +using IRaCIS.Core.Application.Service; +using IRaCIS.Core.Domain.Models; +using IRaCIS.Core.Infra.EFCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace IRaCIS.Core.API.HostService; + + +public class SyncFileRecoveryService(IServiceScopeFactory _scopeFactory, FileSyncQueue _fileSyncQueue) : BackgroundService +{ + + private readonly int _pageSize = 500; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var scope = _scopeFactory.CreateScope(); + var fileUploadRecordRepository = scope.ServiceProvider.GetRequiredService>(); + + // 延迟启动,保证主机快速启动 + await Task.Delay(5000, stoppingToken); + + int page = 0; + + + while (!stoppingToken.IsCancellationRequested) + { + // 分页获取未入队任务 + var pending = await fileUploadRecordRepository + .Where(x => x.IsNeedSync == true && (x.IsSync == false || x.IsSync == null)) + .OrderByDescending(x => x.Priority) + .Select(t => new { t.Id, t.Priority }) + .Skip(page * _pageSize) + .Take(_pageSize) + .ToListAsync(stoppingToken); + + if (!pending.Any()) + break; // 扫描完毕,退出循环 + + foreach (var file in pending) + { + //file.IsQueued = true; // 避免重复入队 + _fileSyncQueue.Enqueue(file.Id, file.Priority ?? 0); // 放入队列 + } + + page++; // 下一页 + await Task.Delay(200, stoppingToken); // 缓解数据库压力 + } + } +} + +public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger _logger, FileSyncQueue _fileSyncQueue) : BackgroundService +{ + + // ⭐ 自动根据服务器CPU + private readonly int _workerCount = Math.Max(1, Environment.ProcessorCount - 1); + + + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + for (int i = 0; i < _workerCount; i++) + Task.Run(() => WorkerLoop(stoppingToken)); + + return Task.CompletedTask; + } + + + private async Task WorkerLoop(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var id = await _fileSyncQueue.DequeueAsync(stoppingToken); + + try + { + using var scope = _scopeFactory.CreateScope(); + var _fileUploadRecordRepository = scope.ServiceProvider.GetRequiredService>(); + var _uploadFileSyncRecordRepository = scope.ServiceProvider.GetRequiredService>(); + var oss = scope.ServiceProvider.GetRequiredService(); + + var file = await _fileUploadRecordRepository.FirstOrDefaultAsync(t => t.Id == id); + + if (file == null || file.IsNeedSync != true) + return; + + var log = new UploadFileSyncRecord + { + FileUploadRecordId = id, + StartTime = DateTime.Now, + JobState = jobState.RUNNING + }; + + await _uploadFileSyncRecordRepository.AddAsync(log); + await _uploadFileSyncRecordRepository.SaveChangesAsync(stoppingToken); + + try + { + await oss.SyncFileAsync(file.Path.TrimStart("/"), file.UploadRegion == "CN" ? ObjectStoreUse.AliyunOSS : ObjectStoreUse.AWS, file.UploadRegion == "CN" ? ObjectStoreUse.AWS : ObjectStoreUse.AliyunOSS); + + file.IsSync = true; + file.SyncFinishedTime = DateTime.UtcNow; + + log.JobState = jobState.SUCCESS; + } + catch (Exception ex) + { + log.JobState = jobState.FAILED; + + log.Msg = ex.Message[..300]; + } + + log.EndTime = DateTime.UtcNow; + + await _uploadFileSyncRecordRepository.SaveChangesAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Sync failed {Id}", id); + } + } + + + } +} + + diff --git a/IRaCIS.Core.API/Progranm.cs b/IRaCIS.Core.API/Progranm.cs index e45bd4215..2567eba24 100644 --- a/IRaCIS.Core.API/Progranm.cs +++ b/IRaCIS.Core.API/Progranm.cs @@ -88,6 +88,9 @@ builder.Services.ConfigureServices(_configuration); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + //minimal api 异常处理 builder.Services.AddExceptionHandler(); //builder.Services.AddProblemDetails(); diff --git a/IRaCIS.Core.API/appsettings.Test_IRC.json b/IRaCIS.Core.API/appsettings.Test_IRC.json index 242e40ac2..965c86c3c 100644 --- a/IRaCIS.Core.API/appsettings.Test_IRC.json +++ b/IRaCIS.Core.API/appsettings.Test_IRC.json @@ -29,16 +29,23 @@ // 使用的对象存储服务类型 "ObjectStoreUse": "AliyunOSS", "IsOpenStoreSync": false, + "ApiDeployRegion": "CN", "SyncConfigList": [ { "Domain": "ir.test.extimaging.com", "Primary": "AliyunOSS", - "Target": "AWS" + "Target": "AWS", + "UploadRegion": "CN", + "TargetRegion": "US", + "IsOpenSync": true }, { "Domain": "lili.test.extimaging.com", "Primary": "AWS", - "Target": "AliyunOSS" + "Target": "AliyunOSS", + "UploadRegion": "US", + "TargetRegion": "CN", + "IsOpenSync": true } ], // 阿里云对象存储服务的配置 diff --git a/IRaCIS.Core.Application/Helper/CacheHelper.cs b/IRaCIS.Core.Application/Helper/CacheHelper.cs index 15bf0008b..804ab7820 100644 --- a/IRaCIS.Core.Application/Helper/CacheHelper.cs +++ b/IRaCIS.Core.Application/Helper/CacheHelper.cs @@ -1,4 +1,6 @@ -namespace IRaCIS.Core.Application.Helper; +using DocumentFormat.OpenXml.Spreadsheet; + +namespace IRaCIS.Core.Application.Helper; public static class CacheKeys @@ -66,6 +68,9 @@ public static class CacheKeys public static string UserMFATag(Guid userId) => $"UserMFAVerifyPass:{userId}"; + public static string TrialDataStoreType(Guid trialId) => $"TrialDataStoreType:{trialId}"; + + } public static class CacheHelper @@ -77,6 +82,9 @@ public static class CacheHelper return statusStr; } + + + public static async Task> GetSystemAnonymizationListAsync(IRepository _systemAnonymizationRepository) { var list = await _systemAnonymizationRepository.Where(t => t.IsEnable).ToListAsync(); diff --git a/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs b/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs index 12c387440..cba05d034 100644 --- a/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs +++ b/IRaCIS.Core.Application/Helper/DicomDIRHelper.cs @@ -1,6 +1,8 @@ using DocumentFormat.OpenXml.Office.CustomUI; using FellowOakDicom; using FellowOakDicom.Media; +using IRaCIS.Core.Application.ViewModel; +using IRaCIS.Core.Domain.Models; using System; using System.Collections.Generic; using System.Data; @@ -58,7 +60,11 @@ namespace IRaCIS.Core.Application.Helper var mappings = new List(); int index = 1; - var studyUid=list.FirstOrDefault()?.StudyInstanceUid; + var trialId = Guid.Empty; + + Guid.TryParse(ossFolder.Split('/', StringSplitOptions.RemoveEmptyEntries)[0], out trialId); + + var studyUid = list.FirstOrDefault()?.StudyInstanceUid; var dicomDir = new DicomDirectory(); @@ -130,9 +136,9 @@ namespace IRaCIS.Core.Application.Helper // 重置流位置 memoryStream.Position = 0; - var relativePath= await _oSSService.UploadToOSSAsync(memoryStream, ossFolder, "DICOMDIR", true); + var relativePath = await _oSSService.UploadToOSSAsync(memoryStream, ossFolder, "DICOMDIR", true, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialId, BatchDataType = BatchDataType.DICOMDIR }); - dic.Add($"{studyUid}_DICOMDIR" , relativePath.Split('/').Last()); + dic.Add($"{studyUid}_DICOMDIR", relativePath.Split('/').Last()); } //清理临时文件 @@ -146,7 +152,7 @@ namespace IRaCIS.Core.Application.Helper var mappingText = string.Join(Environment.NewLine, mappings); await using var mappingStream = new MemoryStream(Encoding.UTF8.GetBytes(mappingText)); - await _oSSService.UploadToOSSAsync(mappingStream, ossFolder, $"Download_{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}", false); + await _oSSService.UploadToOSSAsync(mappingStream, ossFolder, $"Download_{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}", false, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialId, BatchDataType = BatchDataType.DICOMDIR }); #endregion } diff --git a/IRaCIS.Core.Application/Helper/OSSService.cs b/IRaCIS.Core.Application/Helper/OSSService.cs index 572a5e9b5..fbfe4e60a 100644 --- a/IRaCIS.Core.Application/Helper/OSSService.cs +++ b/IRaCIS.Core.Application/Helper/OSSService.cs @@ -7,6 +7,8 @@ using Amazon.S3; using Amazon.S3.Model; using Amazon.SecurityToken; using Amazon.SecurityToken.Model; +using IRaCIS.Core.Application.Interfaces; +using IRaCIS.Core.Application.ViewModel; using IRaCIS.Core.Infrastructure; using IRaCIS.Core.Infrastructure.NewtonsoftJson; using MassTransit; @@ -16,7 +18,9 @@ using Minio; using Minio.DataModel; using Minio.DataModel.Args; using Minio.Exceptions; +using Org.BouncyCastle.Tls; using Serilog.Parsing; +using SkiaSharp; using System.IO; using System.Reactive.Linq; using System.Runtime.InteropServices; @@ -83,6 +87,8 @@ public class ObjectStoreServiceOptions public bool IsOpenStoreSync { get; set; } + public string ApiDeployRegion { get; set; } + public List SyncConfigList { get; set; } = new List(); } @@ -91,10 +97,16 @@ public class SyncStoreConfig { public string Domain { get; set; } + public string UploadRegion { get; set; } + + public string TargetRegion { get; set; } + public string Primary { get; set; } public string Target { get; set; } + public bool IsOpenSync { get; set; } + } public class ObjectStoreDTO @@ -156,6 +168,7 @@ public enum ObjectStoreUse AWS = 2, } + #endregion // aws 参考链接 https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/dotnetv3/S3/S3_Basics @@ -166,8 +179,8 @@ public interface IOSSService public Task RestoreFilesByPrefixAsync(string prefix, int restoreDays = 3, int batchSize = 100); - public Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true); - public Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true, bool randomFileName = false); + public Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true, FileUploadRecordAddOrEdit? uploadInfo = null); + public Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true, bool randomFileName = false, FileUploadRecordAddOrEdit? uploadInfo = null); public Task DownLoadFromOSSAsync(string ossRelativePath, string localFilePath); @@ -183,28 +196,27 @@ public interface IOSSService List GetRootFolderNames(); - public ObjectStoreDTO GetObjectStoreTempToken(string? domain = null); + public ObjectStoreDTO GetObjectStoreTempToken(string? domain = null, bool? isGetAllTempToken = null); public Task MoveObject(string sourcePath, string destPath, bool overwrite = true); public Task GetObjectSizeAsync(string sourcePath); + + public Task SyncFileAsync(string objectKey, ObjectStoreUse source, ObjectStoreUse destination, CancellationToken ct = default); } -public class OSSService : IOSSService +public class OSSService(IOptionsMonitor options, + IFileUploadRecordService _fileUploadRecordService) : IOSSService { - public ObjectStoreServiceOptions ObjectStoreServiceOptions { get; set; } + public ObjectStoreServiceOptions ObjectStoreServiceOptions { get; set; } = options.CurrentValue; private AliyunOSSTempToken AliyunOSSTempToken { get; set; } private AWSTempToken AWSTempToken { get; set; } - public OSSService(IOptionsMonitor options) - { - ObjectStoreServiceOptions = options.CurrentValue; - } /// /// 将指定前缀下的所有现有文件立即转为目标存储类型 @@ -719,6 +731,9 @@ public class OSSService : IOSSService } } + + + /// /// oosFolderPath 不要 "/ "开头 应该: TempFolder/ChildFolder /// @@ -726,8 +741,9 @@ public class OSSService : IOSSService /// /// /// + /// 只用赋值业务参数Id 和批次信息即可,其他信息不用传递 /// - public async Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true) + public async Task UploadToOSSAsync(Stream fileStream, string oosFolderPath, string fileRealName, bool isFileNameAddGuid = true, FileUploadRecordAddOrEdit? uploadInfo = null) { BackBatchGetToken(); @@ -735,13 +751,8 @@ public class OSSService : IOSSService try { - using (var memoryStream = new MemoryStream()) - { - fileStream.Seek(0, SeekOrigin.Begin); - - fileStream.CopyTo(memoryStream); - - memoryStream.Seek(0, SeekOrigin.Begin); + if (fileStream.CanSeek) + fileStream.Seek(0, SeekOrigin.Begin); if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") @@ -753,7 +764,7 @@ public class OSSService : IOSSService // 上传文件 - var result = _ossClient.PutObject(aliConfig.BucketName, ossRelativePath, memoryStream); + var result = _ossClient.PutObject(aliConfig.BucketName, ossRelativePath, fileStream); } else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") @@ -768,8 +779,8 @@ public class OSSService : IOSSService var putObjectArgs = new PutObjectArgs() .WithBucket(minIOConfig.BucketName) .WithObject(ossRelativePath) - .WithStreamData(memoryStream) - .WithObjectSize(memoryStream.Length); + .WithStreamData(fileStream) + .WithObjectSize(fileStream.Length); await minioClient.PutObjectAsync(putObjectArgs); } @@ -793,7 +804,7 @@ public class OSSService : IOSSService var putObjectRequest = new Amazon.S3.Model.PutObjectRequest() { BucketName = awsConfig.BucketName, - InputStream = memoryStream, + InputStream = fileStream, Key = ossRelativePath, }; @@ -803,7 +814,6 @@ public class OSSService : IOSSService { throw new BusinessValidationFailedException("未定义的存储介质类型"); } - } } catch (Exception ex) { @@ -812,13 +822,27 @@ public class OSSService : IOSSService } + var returnPath = "/" + ossRelativePath; - return "/" + ossRelativePath; + if (ObjectStoreServiceOptions.IsOpenStoreSync && uploadInfo != null) + { + uploadInfo.FileSize = fileStream.CanSeek ? fileStream.Length : 0; + uploadInfo.Path = returnPath; + uploadInfo.FileName = fileRealName; + uploadInfo.FileType = Path.GetExtension(returnPath); + + + await _fileUploadRecordService.AddOrUpdateFileUploadRecord(uploadInfo); + } + + + return returnPath; } + //后端批量上传 或者下载,不每个文件获取临时token private void BackBatchGetToken() { @@ -860,10 +884,12 @@ public class OSSService : IOSSService /// 随机文件名 /// /// - public async Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true, bool randomFileName = false) + public async Task UploadToOSSAsync(string localFilePath, string oosFolderPath, bool isFileNameAddGuid = true, bool randomFileName = false, FileUploadRecordAddOrEdit? uploadInfo = null) { BackBatchGetToken(); + long fileSize = 0; + var localFileName = Path.GetFileName(localFilePath); var ossRelativePath = isFileNameAddGuid ? $"{oosFolderPath}/{Guid.NewGuid()}_{localFileName}" : $"{oosFolderPath}/{localFileName}"; @@ -883,6 +909,8 @@ public class OSSService : IOSSService // 上传文件 var result = _ossClient.PutObject(aliConfig.BucketName, ossRelativePath, localFilePath); + fileSize = result.ContentLength; + } else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") { @@ -898,7 +926,9 @@ public class OSSService : IOSSService .WithObject(ossRelativePath) .WithFileName(localFilePath); - await minioClient.PutObjectAsync(putObjectArgs); + var result = await minioClient.PutObjectAsync(putObjectArgs); + + fileSize = result.Size; } else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") { @@ -923,14 +953,30 @@ public class OSSService : IOSSService Key = ossRelativePath, }; - await amazonS3Client.PutObjectAsync(putObjectRequest); - + var result = await amazonS3Client.PutObjectAsync(putObjectRequest); + fileSize = result.ContentLength; } else { throw new BusinessValidationFailedException("未定义的存储介质类型"); } - return "/" + ossRelativePath; + + var returnPath = "/" + ossRelativePath; + + + if (ObjectStoreServiceOptions.IsOpenStoreSync && uploadInfo != null) + { + uploadInfo.FileSize = fileSize; + uploadInfo.Path = returnPath; + uploadInfo.FileName = Path.GetFileName(localFilePath); + uploadInfo.FileType = Path.GetExtension(returnPath); + + + await _fileUploadRecordService.AddOrUpdateFileUploadRecord(uploadInfo); + } + + + return returnPath; } @@ -1042,11 +1088,6 @@ public class OSSService : IOSSService // 直接返回流 return result.Content; - //// 将OSS返回的流复制到内存流中并返回 - //var memoryStream = new MemoryStream(); - //await result.Content.CopyToAsync(memoryStream); - //memoryStream.Position = 0; // 重置位置以便读取 - //return memoryStream; } else if (ObjectStoreServiceOptions.ObjectStoreUse == "MinIO") { @@ -1112,10 +1153,6 @@ public class OSSService : IOSSService // ⭐ 直接返回流 return response.ResponseStream; - //var memoryStream = new MemoryStream(); - //await response.ResponseStream.CopyToAsync(memoryStream); - //memoryStream.Position = 0; - //return memoryStream; } else { @@ -1459,8 +1496,31 @@ public class OSSService : IOSSService /// public async Task DeleteFromPrefix(string prefix, bool isCache = false) { - GetObjectStoreTempToken(); + //打开了同步的,删除的时候,一起删除 + if (ObjectStoreServiceOptions.IsOpenStoreSync && ObjectStoreServiceOptions.SyncConfigList.Any(t => t.IsOpenSync)) + { + foreach (var config in ObjectStoreServiceOptions.SyncConfigList.Where(t => t.IsOpenSync)) + { + ObjectStoreServiceOptions.ObjectStoreUse = config.Primary; + + GetObjectStoreTempToken(); + + await DeleteFromPrefixInternal(prefix, isCache); + } + } + else + { + GetObjectStoreTempToken(); + + await DeleteFromPrefixInternal(prefix, isCache); + } + + } + + + private async Task DeleteFromPrefixInternal(string prefix, bool isCache = false) + { if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") { var aliConfig = ObjectStoreServiceOptions.AliyunOSS; @@ -1610,8 +1670,32 @@ public class OSSService : IOSSService } } + public async Task DeleteObjects(List objectKeys, bool isCache = false) { + //打开了同步的,删除的时候,一起删除 + if (ObjectStoreServiceOptions.IsOpenStoreSync && ObjectStoreServiceOptions.SyncConfigList.Any(t => t.IsOpenSync)) + { + foreach (var config in ObjectStoreServiceOptions.SyncConfigList.Where(t => t.IsOpenSync)) + { + ObjectStoreServiceOptions.ObjectStoreUse = config.Primary; + + GetObjectStoreTempToken(); + + await DeleteObjectsInternal(objectKeys, isCache); + } + } + else + { + GetObjectStoreTempToken(); + + await DeleteObjectsInternal(objectKeys, isCache); + } + } + public async Task DeleteObjectsInternal(List objectKeys, bool isCache = false) + { + + GetObjectStoreTempToken(); if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") @@ -1774,7 +1858,7 @@ public class OSSService : IOSSService - public ObjectStoreDTO GetObjectStoreTempToken(string? domain = null) + public ObjectStoreDTO GetObjectStoreTempToken(string? domain = null, bool? isGetAllTempToken = null) { //如果传递了域名,并且打开了存储同步,根据域名使用的具体存储覆盖之前的配置,否则就用固定的配置 if (ObjectStoreServiceOptions.IsOpenStoreSync && domain.IsNotNullOrEmpty()) @@ -1788,9 +1872,9 @@ public class OSSService : IOSSService } } - var objectStoreDTO = new ObjectStoreDTO() { ObjectStoreUse= ObjectStoreServiceOptions.ObjectStoreUse, IsOpenStoreSync = ObjectStoreServiceOptions.IsOpenStoreSync ,SyncConfigList= ObjectStoreServiceOptions .SyncConfigList}; + var objectStoreDTO = new ObjectStoreDTO() { ObjectStoreUse = ObjectStoreServiceOptions.ObjectStoreUse, IsOpenStoreSync = ObjectStoreServiceOptions.IsOpenStoreSync, SyncConfigList = ObjectStoreServiceOptions.SyncConfigList }; - if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS") + if (ObjectStoreServiceOptions.ObjectStoreUse == "AliyunOSS" || isGetAllTempToken == true) { var ossOptions = ObjectStoreServiceOptions.AliyunOSS; @@ -1842,7 +1926,7 @@ public class OSSService : IOSSService { objectStoreDTO.MinIO = ObjectStoreServiceOptions.MinIO; } - else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS") + else if (ObjectStoreServiceOptions.ObjectStoreUse == "AWS" || isGetAllTempToken == true) { var awsOptions = ObjectStoreServiceOptions.AWS; @@ -1895,4 +1979,58 @@ public class OSSService : IOSSService return objectStoreDTO; } + + public async Task SyncFileAsync(string objectKey, ObjectStoreUse source, ObjectStoreUse destination, CancellationToken ct = default) + { + GetObjectStoreTempToken(isGetAllTempToken: true); + + + var aliConfig = ObjectStoreServiceOptions.AliyunOSS; + + var _ossClient = new OssClient(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? aliConfig.EndPoint : aliConfig.InternalEndpoint, AliyunOSSTempToken.AccessKeyId, AliyunOSSTempToken.AccessKeySecret, AliyunOSSTempToken.SecurityToken); + + var awsConfig = ObjectStoreServiceOptions.AWS; + + var credentials = new SessionAWSCredentials(AWSTempToken.AccessKeyId, AWSTempToken.SecretAccessKey, AWSTempToken.SessionToken); + + //提供awsEndPoint(域名)进行访问配置 + var clientConfig = new AmazonS3Config + { + RegionEndpoint = RegionEndpoint.GetBySystemName(awsConfig.Region) + }; + + var amazonS3Client = new AmazonS3Client(credentials, clientConfig); + + + // 根据源选择流式下载 + Stream sourceStream = source switch + { + ObjectStoreUse.AliyunOSS => _ossClient.GetObject(aliConfig.BucketName, objectKey).Content, + ObjectStoreUse.AWS => (await amazonS3Client.GetObjectAsync(awsConfig.BucketName, objectKey, ct)).ResponseStream, + _ => throw new BusinessValidationFailedException("未定义的同步类型") + }; + + if (source == ObjectStoreUse.AliyunOSS) + { + var putRequest = new Amazon.S3.Model.PutObjectRequest + { + BucketName = awsConfig.BucketName, + Key = objectKey, + InputStream = sourceStream + }; + + await amazonS3Client.PutObjectAsync(putRequest, ct); + } + else if (source == ObjectStoreUse.AWS) + { + _ossClient.PutObject(aliConfig.BucketName, objectKey, sourceStream); + } + else + { + throw new BusinessValidationFailedException("未定义的同步类型"); + } + + await sourceStream.DisposeAsync(); // 释放流 + } + } diff --git a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml index 18090e3a9..62e3ed35b 100644 --- a/IRaCIS.Core.Application/IRaCIS.Core.Application.xml +++ b/IRaCIS.Core.Application/IRaCIS.Core.Application.xml @@ -1556,6 +1556,45 @@ + + + 同步队列 信号量 + + + + + 如果没有任务 → 挂起等待 有任务 → 被唤醒并返回 + + + + + + + 入队任务 + + + + + Worker 等待并获取任务 + + + + + 当前排队数量(调试用) + + + + + 同步调度器 + + + + + 如果没有任务 → 挂起等待 有任务 → 被唤醒并返回 + + + + InternationalizationService @@ -15835,7 +15874,7 @@ - + oosFolderPath 不要 "/ "开头 应该: TempFolder/ChildFolder @@ -15843,9 +15882,10 @@ + 只用赋值业务参数Id 和批次信息即可,其他信息不用传递 - + oosFolderPath 不要 "/ "开头 应该: TempFolder/ChildFolder diff --git a/IRaCIS.Core.Application/MassTransit/Consumer/ConsistencyCheckConsumer.cs b/IRaCIS.Core.Application/MassTransit/Consumer/ConsistencyCheckConsumer.cs index 0038ac6aa..c8f98b101 100644 --- a/IRaCIS.Core.Application/MassTransit/Consumer/ConsistencyCheckConsumer.cs +++ b/IRaCIS.Core.Application/MassTransit/Consumer/ConsistencyCheckConsumer.cs @@ -6,6 +6,7 @@ using IRaCIS.Core.Application.Contracts; using IRaCIS.Core.Application.Contracts.DTO; using IRaCIS.Core.Application.Helper; using IRaCIS.Core.Application.MassTransit.Command; +using IRaCIS.Core.Application.ViewModel; using IRaCIS.Core.Domain.Models; using IRaCIS.Core.Domain.Share; using MassTransit; @@ -320,7 +321,7 @@ namespace IRaCIS.Core.Application.MassTransit.Consumer join study in _studyRepository.AsQueryable() on sv.Id equals study.SubjectVisitId select new CheckDBModel() { - SubjectStatus = sv.Subject.Status, + SubjectStatus = sv.Subject.Status, SubjectVisitId = sv.Id, SiteCode = sv.TrialSite.TrialSiteCode, StudyDate = study.StudyTime == null ? string.Empty : ((DateTime)study.StudyTime).ToString("yyyy-MM-dd"), @@ -403,7 +404,7 @@ namespace IRaCIS.Core.Application.MassTransit.Consumer { SubjectStatus = dbCurrentVisitFirst.SubjectStatus, CheckTime = DateTime.Now, - CheckState=CheckStateEnum.CVPassed, + CheckState = CheckStateEnum.CVPassed, SiteCode = dbCurrentVisitFirst.SiteCode, SubjectCode = dbCurrentVisitFirst.SubjectCode, VisitName = dbCurrentVisitFirst.VisitName, @@ -448,7 +449,7 @@ namespace IRaCIS.Core.Application.MassTransit.Consumer var fileStreamResult = (FileStreamResult)await ExcelExportHelper.DataExportAsync(StaticData.Export.TrialConsistentFUllCheckList_Export, exportInfo, exportInfo.TrialCode, _commonDocumentRepository, _hostEnvironment, _dictionaryService, typeof(FullCheckResult)); - var ossRelativePath = await _oSSService.UploadToOSSAsync(fileStreamResult.FileStream, $"{trialId.ToString()}/InspectionUpload/DataReconciliation", "DataReconciliation"); + var ossRelativePath = await _oSSService.UploadToOSSAsync(fileStreamResult.FileStream, $"{trialId.ToString()}/InspectionUpload/DataReconciliation", "DataReconciliation", uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialId, BatchDataType = BatchDataType.DataReconciliation }); //var add = await _inspectionFileRepository.FindAsync(inspectionFileId); diff --git a/IRaCIS.Core.Application/Service/Common/DTO/FileUploadRecordViewModel.cs b/IRaCIS.Core.Application/Service/Common/DTO/FileUploadRecordViewModel.cs new file mode 100644 index 000000000..961cd08b3 --- /dev/null +++ b/IRaCIS.Core.Application/Service/Common/DTO/FileUploadRecordViewModel.cs @@ -0,0 +1,107 @@ + +//-------------------------------------------------------------------- +// 此代码由liquid模板自动生成 byzhouhang 20240909 +// 生成时间 2026-03-10 06:15:31Z +// 对此文件的更改可能会导致不正确的行为,并且如果重新生成代码,这些更改将会丢失。 +//-------------------------------------------------------------------- +using System; +using IRaCIS.Core.Domain.Share; +using System.Collections.Generic; +namespace IRaCIS.Core.Application.ViewModel; + +public class FileUploadRecordView : FileUploadRecordAddOrEdit +{ + + public DateTime CreateTime { get; set; } + + public DateTime UpdateTime { get; set; } + + public bool? IsSync { get; set; } + + + +} + + +public class FileUploadRecordAddOrEdit +{ + public Guid? Id { get; set; } + + + public string FileName { get; set; } + + public long FileSize { get; set; } + + public string FileType { get; set; } + + public string Path { get; set; } + + + public string UploadBatchId { get; set; } + public BatchDataType BatchDataType { get; set; } + + + public Guid? TrialId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? SubjectVisitId { get; set; } + + public Guid? DicomStudyId { get; set; } + + public Guid? NoneDicomStudyId { get; set; } + + + + public string FileMarkId { get; set; } + + public int? Priority { get; set; } + public string IP { get; set; } + public bool? IsNeedSync { get; set; } + public string UploadRegion { get; set; } + public string TargetRegion { get; set; } +} + +public class FileUploadRecordQuery : PageInput +{ + public BatchDataType? BatchDataType { get; set; } + + + public string? FileMarkId { get; set; } + + public string? FileName { get; set; } + + + public string? FileType { get; set; } + + public string? IP { get; set; } + + public bool? IsNeedSync { get; set; } + + public bool? IsSync { get; set; } + + + public string? Path { get; set; } + + public int? Priority { get; set; } + + public string? TargetRegion { get; set; } + + + public string? UploadBatchId { get; set; } + + public string? UploadRegion { get; set; } + + + public DateTime? SyncFinishedStartTime { get; set; } + + public DateTime? SyncFinishedEndTime { get; set; } + + public DateTime? UploadStartTime { get; set; } + + public DateTime? UploadEndTime { get; set; } +} + + + + diff --git a/IRaCIS.Core.Application/Service/Common/EmailLogService.cs b/IRaCIS.Core.Application/Service/Common/EmailLogService.cs index b83532e7e..092c11977 100644 --- a/IRaCIS.Core.Application/Service/Common/EmailLogService.cs +++ b/IRaCIS.Core.Application/Service/Common/EmailLogService.cs @@ -155,7 +155,8 @@ public class EmailLogService(IRepository _emailLogRepository, fileStream: decodeStream, oosFolderPath: $"EmailAttachment/{emailInfo.Id}", // OSS 虚拟目录 fileRealName: emaliAttachmentInfo.AttachmentName, - isFileNameAddGuid: true); // 让方法自己在文件名前加 Guid + isFileNameAddGuid: true, + uploadInfo: new FileUploadRecordAddOrEdit() {TrialId= inDto.TrialId, BatchDataType = BatchDataType.EmailAttach }); // 让方法自己在文件名前加 Guid attachmentInfos.Add(emaliAttachmentInfo); } diff --git a/IRaCIS.Core.Application/Service/Common/FileUploadRecordService.cs b/IRaCIS.Core.Application/Service/Common/FileUploadRecordService.cs new file mode 100644 index 000000000..4e8f7314c --- /dev/null +++ b/IRaCIS.Core.Application/Service/Common/FileUploadRecordService.cs @@ -0,0 +1,292 @@ + +//-------------------------------------------------------------------- +// 此代码由liquid模板自动生成 byzhouhang 20240909 +// 生成时间 2026-03-10 06:15:17Z +// 对此文件的更改可能会导致不正确的行为,并且如果重新生成代码,这些更改将会丢失。 +//-------------------------------------------------------------------- +using IRaCIS.Core.Application.Helper; +using IRaCIS.Core.Application.Interfaces; +using IRaCIS.Core.Application.ViewModel; +using IRaCIS.Core.Domain.Models; +using IRaCIS.Core.Infra.EFCore; +using IRaCIS.Core.Infra.EFCore.Common; +using IRaCIS.Core.Infrastructure.Extention; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Spire.Doc.Interface; +using System.Drawing; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace IRaCIS.Core.Application.Service; + +[ApiExplorerSettings(GroupName = "Common")] +public class FileUploadRecordService(IRepository _fileUploadRecordRepository, + IMapper _mapper, IUserInfo _userInfo, IStringLocalizer _localizer, IOptionsMonitor options, + IFusionCache _fusionCache, IRepository _trialRepository, FileSyncQueue _fileSyncQueue) : BaseService, IFileUploadRecordService +{ + + ObjectStoreServiceOptions ObjectStoreServiceConfig = options.CurrentValue; + + [HttpPost] + public async Task> GetFileUploadRecordList(FileUploadRecordQuery inQuery) + { + + var fileUploadRecordQueryable = _fileUploadRecordRepository + .WhereIf(inQuery.BatchDataType != null, t => t.BatchDataType == inQuery.BatchDataType) + .WhereIf(!string.IsNullOrEmpty(inQuery.FileName), t => t.FileName.Contains(inQuery.FileName)) + .WhereIf(!string.IsNullOrEmpty(inQuery.FileType), t => t.FileType.Contains(inQuery.FileType)) + .WhereIf(inQuery.IsSync != null, t => t.IsSync == inQuery.IsSync) + .WhereIf(inQuery.Priority != null, t => t.Priority == inQuery.Priority) + .WhereIf(inQuery.BatchDataType != null, t => t.BatchDataType == inQuery.BatchDataType) + .WhereIf(!string.IsNullOrEmpty(inQuery.UploadRegion), t => t.UploadRegion.Contains(inQuery.UploadRegion)) + .WhereIf(!string.IsNullOrEmpty(inQuery.TargetRegion), t => t.TargetRegion.Contains(inQuery.TargetRegion)) + .WhereIf(!string.IsNullOrEmpty(inQuery.UploadBatchId), t => t.UploadBatchId.Contains(inQuery.UploadBatchId)) + .WhereIf(!string.IsNullOrEmpty(inQuery.Path), t => t.Path.Contains(inQuery.Path)) + .WhereIf(inQuery.UploadStartTime != null, t => t.CreateTime >= inQuery.UploadStartTime) + .WhereIf(inQuery.UploadEndTime != null, t => t.CreateTime <= inQuery.UploadEndTime) + .WhereIf(inQuery.SyncFinishedStartTime != null, t => t.SyncFinishedTime >= inQuery.SyncFinishedStartTime) + .WhereIf(inQuery.SyncFinishedEndTime != null, t => t.SyncFinishedTime <= inQuery.SyncFinishedEndTime) + + .ProjectTo(_mapper.ConfigurationProvider); + + var pageList = await fileUploadRecordQueryable.ToPagedListAsync(inQuery); + + return pageList; + } + + + + + public async Task AddOrUpdateFileUploadRecord(FileUploadRecordAddOrEdit addOrEditFileUploadRecord) + { + + addOrEditFileUploadRecord.IP = _userInfo.IP; + + if (ObjectStoreServiceConfig.IsOpenStoreSync && _userInfo.Domain.IsNotNullOrEmpty()) + { + var find = ObjectStoreServiceConfig.SyncConfigList.FirstOrDefault(t => t.Domain == _userInfo.Domain); + if (find != null) + { + addOrEditFileUploadRecord.UploadRegion = find.UploadRegion; + addOrEditFileUploadRecord.TargetRegion = find.TargetRegion; + + } + } + + if (addOrEditFileUploadRecord.TrialId != null) + { + + var trialDataStore = await _fusionCache.GetOrSetAsync(CacheKeys.TrialDataStoreType(addOrEditFileUploadRecord.TrialId.Value), async _ => + { + return await _trialRepository.Where(t => t.Id == addOrEditFileUploadRecord.TrialId).Select(t => t.TrialDataStoreType) + .FirstOrDefaultAsync(); + }, + TimeSpan.FromDays(7) + ); + + //项目配置了,那么就设置需要同步 + if (trialDataStore == TrialDataStore.MUtiCenter) + { + addOrEditFileUploadRecord.IsNeedSync = true; + + addOrEditFileUploadRecord.Priority = 0; + + + } + else + { + addOrEditFileUploadRecord.TargetRegion = ""; + + } + + } + else + { + //系统文件,默认同步 + addOrEditFileUploadRecord.IsNeedSync = true; + + addOrEditFileUploadRecord.Priority = 0; + } + + var entity = await _fileUploadRecordRepository.InsertOrUpdateAsync(addOrEditFileUploadRecord, true); + + if (addOrEditFileUploadRecord.IsNeedSync == true) + { + _fileSyncQueue.Enqueue(entity.Id, addOrEditFileUploadRecord.Priority ?? 0); + } + + return ResponseOutput.Ok(entity.Id.ToString()); + + } + + + [HttpDelete("{fileUploadRecordId:guid}")] + public async Task DeleteFileUploadRecord(Guid fileUploadRecordId) + { + var success = await _fileUploadRecordRepository.BatchDeleteNoTrackingAsync(t => t.Id == fileUploadRecordId); + return ResponseOutput.Ok(); + } + +} + + +#region 同步队列 + + + +/// +/// 同步队列 信号量 +/// +public class FileSyncQueue +{ + private readonly PriorityQueue _queue = new(); + private readonly SemaphoreSlim _signal = new(0); + private readonly object _lock = new(); + + public void Enqueue(Guid id, int priority) + { + lock (_lock) + { + // priority 越大越优先 + _queue.Enqueue(id, -priority); + } + + //类似于计数器,不会产生通知风暴,可消费资源 +1 + //if (有等待线程) 唤醒一个 else 仅增加计数 + _signal.Release(); // 唤醒一个 worker + } + + /// + /// 如果没有任务 → 挂起等待 有任务 → 被唤醒并返回 + /// + /// + /// + public async Task DequeueAsync(CancellationToken ct) + { + await _signal.WaitAsync(ct); + + lock (_lock) + { + return _queue.Dequeue(); + } + } +} + +#region 这里不用 SyncQueueUseChannel 和调度器 SyncScheduler +public class SyncQueueUseChannel +{ + // 优先级队列(priority 越大越先执行) + private readonly PriorityQueue _queue = new(); + + // Worker 唤醒信号 + private readonly Channel _signal = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false + }); + + // 队列任务数量(不是CPU数量!) + private int _count = 0; + + + private readonly object _lock = new(); + + /// + /// 入队任务 + /// + public void Enqueue(Guid id, int priority) + { + bool needSignal = false; + + lock (_lock) + { + // priority 越大越优先 → 转负数 + _queue.Enqueue(id, -priority); + + // 只有从 0 → 1 才需要唤醒 worker + if (_count == 0) + needSignal = true; + + _count++; + } + + // 避免 signal 风暴 + if (needSignal) + _signal.Writer.TryWrite(true); + } + + /// + /// Worker 等待并获取任务 + /// + public async Task DequeueAsync(CancellationToken ct) + { + + // 没任务时挂起(不会占CPU) + await _signal.Reader.ReadAsync(ct); + + lock (_lock) + { + var id = _queue.Dequeue(); + + _count--; + + // 如果还有任务,继续唤醒下一个 worker + if (_count > 0) + _signal.Writer.TryWrite(true); + + return id; + } + } + + /// + /// 当前排队数量(调试用) + /// + public int Count + { + get + { + lock (_lock) + return _count; + } + } +} + +/// +/// 同步调度器 +/// +public class FileSyncScheduler +{ + private readonly FileSyncQueue _queue; + + public FileSyncScheduler(FileSyncQueue queue) + { + _queue = queue; + } + + public void Enqueue(FileUploadRecord file) + { + if (file.IsNeedSync != true) + return; + + _queue.Enqueue(file.Id, file.Priority ?? 0); + } + + /// + /// 如果没有任务 → 挂起等待 有任务 → 被唤醒并返回 + /// + /// + /// + public Task WaitAsync(CancellationToken ct) + => _queue.DequeueAsync(ct); +} +#endregion + + + + + + +#endregion + diff --git a/IRaCIS.Core.Application/Service/Common/Interface/IFileUploadRecordService.cs b/IRaCIS.Core.Application/Service/Common/Interface/IFileUploadRecordService.cs new file mode 100644 index 000000000..eebc3e5cd --- /dev/null +++ b/IRaCIS.Core.Application/Service/Common/Interface/IFileUploadRecordService.cs @@ -0,0 +1,23 @@ + +//-------------------------------------------------------------------- +// 此代码由liquid模板自动生成 byzhouhang 20240909 +// 生成时间 2026-03-10 06:15:31Z +// 对此文件的更改可能会导致不正确的行为,并且如果重新生成代码,这些更改将会丢失。 +//-------------------------------------------------------------------- +using System; +using IRaCIS.Core.Infrastructure.Extention; +using System.Threading.Tasks; +using IRaCIS.Core.Application.ViewModel; +namespace IRaCIS.Core.Application.Interfaces; + +public interface IFileUploadRecordService +{ + + Task> GetFileUploadRecordList(FileUploadRecordQuery inQuery); + + Task AddOrUpdateFileUploadRecord(FileUploadRecordAddOrEdit addOrEditFileUploadRecord); + + Task DeleteFileUploadRecord(Guid fileUploadRecordId); +} + + diff --git a/IRaCIS.Core.Application/Service/Common/_MapConfig.cs b/IRaCIS.Core.Application/Service/Common/_MapConfig.cs index cd499bc0a..83c869aa7 100644 --- a/IRaCIS.Core.Application/Service/Common/_MapConfig.cs +++ b/IRaCIS.Core.Application/Service/Common/_MapConfig.cs @@ -124,6 +124,9 @@ namespace IRaCIS.Core.Application.Service CreateMap(); CreateMap(); + + CreateMap(); + CreateMap().ReverseMap(); } } diff --git a/IRaCIS.Core.Application/Service/ImageAndDoc/DownloadAndUploadService.cs b/IRaCIS.Core.Application/Service/ImageAndDoc/DownloadAndUploadService.cs index 8125a7723..2e16b6dd4 100644 --- a/IRaCIS.Core.Application/Service/ImageAndDoc/DownloadAndUploadService.cs +++ b/IRaCIS.Core.Application/Service/ImageAndDoc/DownloadAndUploadService.cs @@ -2582,7 +2582,7 @@ namespace IRaCIS.Core.Application.Service.ImageAndDoc } - + /// /// 后台任务调用,前端忽略该接口 /// diff --git a/IRaCIS.Core.Application/Service/ReadingCalculate/General/GeneralCalculateService.cs b/IRaCIS.Core.Application/Service/ReadingCalculate/General/GeneralCalculateService.cs index 8618fb3a6..01f7dc0b0 100644 --- a/IRaCIS.Core.Application/Service/ReadingCalculate/General/GeneralCalculateService.cs +++ b/IRaCIS.Core.Application/Service/ReadingCalculate/General/GeneralCalculateService.cs @@ -1,6 +1,7 @@ using DocumentFormat.OpenXml.Drawing.Spreadsheet; using IRaCIS.Core.Application.Helper; using IRaCIS.Core.Application.Service.Reading.Dto; +using IRaCIS.Core.Application.ViewModel; using IRaCIS.Core.Domain.Models; using IRaCIS.Core.Domain.Share; using IRaCIS.Core.Infra.EFCore.Common; @@ -47,7 +48,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate /// /// public async Task> GetReadingReportQuestion( - List? questionList, + List? questionList, List taskInfoList, List? globalanswerList, List? answers, @@ -217,7 +218,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate /// 复制既往新病灶答案 /// /// - public async Task CopyHistoryAnswer(VisitTask taskinfo, List tableRowList,List tableAnswerList) + public async Task CopyHistoryAnswer(VisitTask taskinfo, List tableRowList, List tableAnswerList) { if (_userInfo.RequestUrl == "ReadingImageTask/resetReadingTask") { @@ -226,16 +227,16 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate if (taskinfo.IsCopyLesionAnswer) { - var historyTaskId = await _visitTaskRepository.Where(x => + var historyTaskId = await _visitTaskRepository.Where(x => x.ReadingCategory == ReadingCategory.Visit && x.TrialReadingCriterionId == taskinfo.TrialReadingCriterionId && x.IsAnalysisCreate == taskinfo.IsAnalysisCreate && x.DoctorUserId == taskinfo.DoctorUserId && x.IsSelfAnalysis == taskinfo.IsSelfAnalysis && - x.SubjectId == taskinfo.SubjectId && - x.ReadingTaskState == ReadingTaskState.HaveSigned && - x.VisitTaskNum == taskinfo.VisitTaskNum && - x.TaskState != TaskState.Effect && + x.SubjectId == taskinfo.SubjectId && + x.ReadingTaskState == ReadingTaskState.HaveSigned && + x.VisitTaskNum == taskinfo.VisitTaskNum && + x.TaskState != TaskState.Effect && x.ArmEnum == taskinfo.ArmEnum) .OrderByDescending(x => x.SignTime) .Select(x => x.Id).FirstOrDefaultAsync(); @@ -251,9 +252,9 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate var answerList = await _readingTaskQuestionAnswerRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); - var questionMarkList=await _readingTaskQuestionMarkRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); - var noneDicomMarkList=await _readingNoneDicomMarkRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); - var noneDicomMarkBindingList=await _readingNoneDicomMarkBindingRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); + var questionMarkList = await _readingTaskQuestionMarkRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); + var noneDicomMarkList = await _readingNoneDicomMarkRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); + var noneDicomMarkBindingList = await _readingNoneDicomMarkBindingRepository.Where(x => x.VisitTaskId == historyTaskId).ToListAsync(); foreach (var item in tableRowList) { @@ -261,32 +262,32 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate { continue; } - var historyRow = historyTableRowList.Where(x => - x.QuestionId == item.QuestionId && - x.RowIndex == item.RowIndex && - x.IdentityRowId==item.IdentityRowId && - x.IdentityRowId !=null && - x.OrganInfoId==item.OrganInfoId + var historyRow = historyTableRowList.Where(x => + x.QuestionId == item.QuestionId && + x.RowIndex == item.RowIndex && + x.IdentityRowId == item.IdentityRowId && + x.IdentityRowId != null && + x.OrganInfoId == item.OrganInfoId ).FirstOrDefault(); if (historyRow != null) { - item.StudyId= historyRow.StudyId; - item.SeriesId= historyRow.SeriesId; - item.InstanceId= historyRow.InstanceId; + item.StudyId = historyRow.StudyId; + item.SeriesId = historyRow.SeriesId; + item.InstanceId = historyRow.InstanceId; item.OtherStudyId = historyRow.OtherStudyId; item.OtherSeriesId = historyRow.OtherSeriesId; - item.OtherInstanceId= historyRow.OtherInstanceId; + item.OtherInstanceId = historyRow.OtherInstanceId; - item.MeasureData= historyRow.MeasureData.Replace(historyTaskId.ToString(),taskinfo.Id.ToString()); + item.MeasureData = historyRow.MeasureData.Replace(historyTaskId.ToString(), taskinfo.Id.ToString()); item.OtherMeasureData = historyRow.OtherMeasureData.Replace(historyTaskId.ToString(), taskinfo.Id.ToString()); tableAnswerList.Where(x => x.RowId == item.Id).ForEach(x => { x.Answer = x.Answer.IsNullOrEmpty() ? - historyTableAnswerList.Where(y => y.RowId == historyRow.Id && y.TableQuestionId == x.TableQuestionId).Select(x => x.Answer).FirstOrDefault()??string.Empty : + historyTableAnswerList.Where(y => y.RowId == historyRow.Id && y.TableQuestionId == x.TableQuestionId).Select(x => x.Answer).FirstOrDefault() ?? string.Empty : x.Answer; }); @@ -304,7 +305,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate // 处理标记 - Dictionary dicomKeys=new Dictionary (); + Dictionary dicomKeys = new Dictionary(); foreach (var item in questionMarkList) { @@ -336,7 +337,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate Dictionary noneKeys = new Dictionary(); foreach (var item in noneDicomMarkList) { - var newid= NewId.NextGuid(); + var newid = NewId.NextGuid(); item.VisitTaskId = taskinfo.Id; if (item.MarkId != null) @@ -372,7 +373,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate } } - + } #endregion @@ -388,17 +389,17 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate } } - + } /// /// 从上传文件中获取Datatable /// /// - public async Task GetDataTableFromUpload(IFormFile file,string pathCode,Guid trialId) + public async Task GetDataTableFromUpload(IFormFile file, string pathCode, Guid trialId) { - FileToDataTableDto result=new FileToDataTableDto (); + FileToDataTableDto result = new FileToDataTableDto(); result.DataTable = new DataTable(); var fileFolder = "Upload\\"; @@ -419,7 +420,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate file.CopyTo(stream); await stream.CopyToAsync(fileStream); - result.SheetNames= stream.GetSheetNames(); + result.SheetNames = stream.GetSheetNames(); stream.Position = 0; result.DataTable = stream.QueryAsDataTable(useHeaderRow: false); } @@ -432,15 +433,15 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate try { - var ossRelativePath = await oSSService.UploadToOSSAsync(fileStream, "InspectionUpload/"+ pathCode, file.FileName); + var ossRelativePath = await oSSService.UploadToOSSAsync(fileStream, $"{trialId}/InspectionUpload/" + pathCode, file.FileName, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialId, BatchDataType = BatchDataType.ReadingImportTemplete }); await _inspectionFileRepository.AddAsync(new InspectionFile() { FileName = file.FileName, RelativePath = ossRelativePath, TrialId = trialId }); } catch (Exception) { - + } - + @@ -457,7 +458,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate // 遍历每一列,检查值 foreach (var item in row.ItemArray) { - if (item!=null&&!item.ToString().IsNullOrEmpty()) + if (item != null && !item.ToString().IsNullOrEmpty()) { allEmpty = false; break; // 只要有一个不为空,跳出循环 @@ -532,7 +533,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate /// public async Task GetReadingCalculateDto(Guid visitTaskId) { - var visitTask = await _visitTaskRepository.Where(x => x.Id == visitTaskId).Include(x=>x.SourceSubjectVisit).FirstNotNullAsync(); + var visitTask = await _visitTaskRepository.Where(x => x.Id == visitTaskId).Include(x => x.SourceSubjectVisit).FirstNotNullAsync(); var criterionInfo = await _readingQuestionCriterionTrialRepository.Where(x => x.Id == visitTask.TrialReadingCriterionId).FirstNotNullAsync(); var subjectVisit = await _subjectVisitRepository.Where(x => x.Id == (visitTask.SourceSubjectVisitId ?? default(Guid))).FirstOrDefaultAsync(); @@ -540,7 +541,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate var baseLineVisitId = await _subjectVisitRepository.Where(x => x.SubjectId == visitTask.SubjectId && x.IsBaseLine).Select(x => x.Id).FirstOrDefaultAsync(); - var rowInfoList = await _readingTableAnswerRowInfoRepository.Where(x => x.VisitTaskId == visitTaskId).Include(x=>x.FristAddTask).ToListAsync(); + var rowInfoList = await _readingTableAnswerRowInfoRepository.Where(x => x.VisitTaskId == visitTaskId).Include(x => x.FristAddTask).ToListAsync(); var baseLinetaskId = await _visitTaskRepository.Where(x => x.SourceSubjectVisitId == baseLineVisitId && x.TaskState == TaskState.Effect && x.TrialReadingCriterionId == visitTask.TrialReadingCriterionId diff --git a/IRaCIS.Core.Application/Service/ReadingCalculate/General/ReadingCalculateService.cs b/IRaCIS.Core.Application/Service/ReadingCalculate/General/ReadingCalculateService.cs index 6c7f0a060..e2ce0605f 100644 --- a/IRaCIS.Core.Application/Service/ReadingCalculate/General/ReadingCalculateService.cs +++ b/IRaCIS.Core.Application/Service/ReadingCalculate/General/ReadingCalculateService.cs @@ -83,7 +83,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate await file.CopyToAsync(streamCopy); // 重置流的位置,以便后续读取 streamCopy.Position = 0; - var ossRelativePath = await oSSService.UploadToOSSAsync(streamCopy, $"{visitTaskInfo.TrialId.ToString()}/InspectionUpload/ReadingImport", file.FileName); + var ossRelativePath = await oSSService.UploadToOSSAsync(streamCopy, $"{visitTaskInfo.TrialId.ToString()}/InspectionUpload/ReadingImport", file.FileName, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = visitTaskInfo.TrialId, BatchDataType = BatchDataType.ReadingImportTemplete }); await _readingImportFileRepository.AddAsync(new ReadingImportFile() @@ -348,13 +348,14 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate } - private async Task GetReportsChartTypeData(GetReportsChartTypeDataInDto inDto) + private async Task GetReportsChartTypeData(GetReportsChartTypeDataInDto inDto) { var visitTaskNameList = inDto.Data.VisitTaskList.Select(x => x.BlindName).ToList(); - GetReportsChartDataOutDto result = new GetReportsChartDataOutDto() { - - ChartDataList=new List() { }, - VisitTaskNameList= visitTaskNameList, + GetReportsChartDataOutDto result = new GetReportsChartDataOutDto() + { + + ChartDataList = new List() { }, + VisitTaskNameList = visitTaskNameList, }; switch (inDto.ReportChartTypeEnum) @@ -455,30 +456,30 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate public async Task GetReportsChartSummary(GetReportsChartSummaryInDto inDto) { var criterion = await _readingQuestionCriterionTrialRepository.Where(x => x.Id == inDto.TrialCriterionId).FirstNotNullAsync(); - var result= new GetReportsChartSummaryOutDto(); + var result = new GetReportsChartSummaryOutDto(); var r1Data = await GetData(new List() { Arm.SingleReadingArm, Arm.DoubleReadingArm1 }); var r2Data = await GetData(new List() { Arm.DoubleReadingArm2 }); - var alldata = r1Data.VisitTaskList.Count() > r2Data.VisitTaskList.Count() ? r1Data : r2Data; + var alldata = r1Data.VisitTaskList.Count() > r2Data.VisitTaskList.Count() ? r1Data : r2Data; var visitTaskName = alldata.VisitTaskList.Select(x => x.BlindName).ToList(); var length = alldata.VisitTaskList.Count(); // -1转为空 - List negativeToString = new List() + List negativeToString = new List() { QuestionType.DaysBetween, }; - + async Task GetData(List arms) { var data = new GetReadingReportEvaluationOutDto() { }; var task = await _visitTaskRepository.Where(x => x.SubjectId == inDto.SubjectId && arms.Contains(x.ArmEnum) - && x.ReadingCategory== ReadingCategory.Visit + && x.ReadingCategory == ReadingCategory.Visit && x.ReadingTaskState == ReadingTaskState.HaveSigned && x.TaskState == TaskState.Effect - && x.TrialReadingCriterionId==inDto.TrialCriterionId + && x.TrialReadingCriterionId == inDto.TrialCriterionId ).OrderByDescending(x => x.VisitTaskNum).FirstOrDefaultAsync(); if (task != null) { @@ -499,13 +500,13 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate Evaluation = new List>() { } }; - var baseLineAnswerType= QuestionType.ExistDisease; + var baseLineAnswerType = QuestionType.ExistDisease; var visitAnswerType = QuestionType.Tumor; switch (criterion.CriterionType) { case CriterionType.PCWG3: - baseLineAnswerType= QuestionType.SiteVisitForTumorEvaluation; + baseLineAnswerType = QuestionType.SiteVisitForTumorEvaluation; visitAnswerType = QuestionType.SiteVisitForTumorEvaluation; break; case CriterionType.Lugano2014WithoutPET: @@ -515,14 +516,15 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate visitAnswerType = QuestionType.ImgOncology; break; } - - result.Evaluation.Add(visitTaskName.Select(x=> new EvaluationValue() { - Value=x + + result.Evaluation.Add(visitTaskName.Select(x => new EvaluationValue() + { + Value = x }).ToList()); - var r1baseLine= r1.TaskQuestions + var r1baseLine = r1.TaskQuestions .SelectMany(x => x.Childrens) .Where(x => x.QuestionType == baseLineAnswerType) .SelectMany(x => x.Answer.Select(a => new EvaluationValue @@ -545,7 +547,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate r1data = r1baseLine.Take(1).Concat(r1data.Skip(1)).ToList(); - r1data= r1data.Concat(Enumerable.Repeat(new EvaluationValue() { Value = "" }, length)) + r1data = r1data.Concat(Enumerable.Repeat(new EvaluationValue() { Value = "" }, length)) .Take(length) .ToList(); result.Evaluation.Add(r1data); @@ -581,7 +583,7 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate .ToList(); result.Evaluation.Add(r2data); } - + return result; } @@ -597,17 +599,17 @@ namespace IRaCIS.Core.Application.Service.ReadingCalculate var chartList = new List(); foreach (var item in items) { - var itemdata= data.TaskQuestions.SelectMany(x => x.Childrens) + var itemdata = data.TaskQuestions.SelectMany(x => x.Childrens) .Where(x => x.QuestionId == item.QuestionId) .FirstOrDefault(); var cd = new ReportChartData { - Name = item?.QuestionName??string.Empty, + Name = item?.QuestionName ?? string.Empty, Value = itemdata?.Answer?.Select(a => a.Answer).ToList() ?? new List() }; if (negativeToString.Contains(item.QuestionType)) { - cd.Value = cd.Value.Select(item => item == "-1"|| item == "-2" ? string.Empty : item).ToList(); + cd.Value = cd.Value.Select(item => item == "-1" || item == "-2" ? string.Empty : item).ToList(); } chartList.Add(cd); } diff --git a/IRaCIS.Core.Application/Service/TrialSiteUser/TrialConfigService.cs b/IRaCIS.Core.Application/Service/TrialSiteUser/TrialConfigService.cs index f697c855c..41a216814 100644 --- a/IRaCIS.Core.Application/Service/TrialSiteUser/TrialConfigService.cs +++ b/IRaCIS.Core.Application/Service/TrialSiteUser/TrialConfigService.cs @@ -341,7 +341,7 @@ namespace IRaCIS.Core.Application foreach (var item in systemCriterionKeyFile) { - var path = await _oSSService.UploadToOSSAsync(item.FilePath, $"{trialCriterion.TrialId}/ReadingModule/{trialCriterion.CriterionName}", true, true); + var path = await _oSSService.UploadToOSSAsync(item.FilePath, $"{trialCriterion.TrialId}/ReadingModule/{trialCriterion.CriterionName}", true, true, uploadInfo: new FileUploadRecordAddOrEdit() { TrialId = trialCriterion.TrialId ,BatchDataType=BatchDataType.ReadingKeyFile }); trialCriterionKeyFiles.Add(new TrialCriterionKeyFile { @@ -1559,13 +1559,13 @@ namespace IRaCIS.Core.Application [AllowAnonymous] public async Task GetTrialExtralConfig(Guid trialId) { - var extralObj = _trialRepository.Where(t => t.Id == trialId).Select(t => new { t.TrialExtraConfigJsonStr, t.IsExternalViewTrialChart, t.TrialObjectNameList, t.CollectImagesEnum, t.IsIQCAutoNextTask,t.IsImageQualityControl }).FirstOrDefault(); + var extralObj = _trialRepository.Where(t => t.Id == trialId).Select(t => new { t.TrialExtraConfigJsonStr, t.IsExternalViewTrialChart, t.TrialObjectNameList, t.CollectImagesEnum, t.IsIQCAutoNextTask, t.IsImageQualityControl }).FirstOrDefault(); var extralConfig = JsonConvert.DeserializeObject(extralObj?.TrialExtraConfigJsonStr) ?? new TrialExtraConfig(); var trialConfig = _mapper.Map(extralConfig); - trialConfig.IsImageQualityControl= extralObj.IsImageQualityControl; + trialConfig.IsImageQualityControl = extralObj.IsImageQualityControl; trialConfig.TrialObjectNameList = extralObj.TrialObjectNameList; trialConfig.IsExternalViewTrialChart = extralObj.IsExternalViewTrialChart; trialConfig.CollectImagesEnum = extralObj.CollectImagesEnum; diff --git a/IRaCIS.Core.Domain/Image/FileUploadRecord.cs b/IRaCIS.Core.Domain/Common/FileUploadRecord.cs similarity index 87% rename from IRaCIS.Core.Domain/Image/FileUploadRecord.cs rename to IRaCIS.Core.Domain/Common/FileUploadRecord.cs index 09808e10d..44181ae53 100644 --- a/IRaCIS.Core.Domain/Image/FileUploadRecord.cs +++ b/IRaCIS.Core.Domain/Common/FileUploadRecord.cs @@ -35,7 +35,7 @@ public class FileUploadRecord : BaseFullAuditEntity public string UploadBatchId { get; set; } [Comment("该批次数据类型")] - public int BatchDataType { get; set; } + public BatchDataType BatchDataType { get; set; } [Comment("上传区域")] public string UploadRegion { get; set; } @@ -108,4 +108,26 @@ public enum jobState CANCELLED = 4 +} + +public enum BatchDataType +{ + //前端自定义 1-99 + //后端自定义100开始 + + + DataReconciliation=100, + + SiteUserSurvey=101, + + DICOMDIR = 102, + + EmailAttach=103, + + ReadingImportTemplete=105, + + ReadingKeyFile=106, + + PACSReceive = 107 + } \ No newline at end of file diff --git a/IRaCIS.Core.Infra.EFCore/AuthUser/IUserInfo.cs b/IRaCIS.Core.Infra.EFCore/AuthUser/IUserInfo.cs index c3391264b..2be9983da 100644 --- a/IRaCIS.Core.Infra.EFCore/AuthUser/IUserInfo.cs +++ b/IRaCIS.Core.Infra.EFCore/AuthUser/IUserInfo.cs @@ -33,6 +33,8 @@ string IP { get; } + string Domain { get; } + string LocalIp { get; } bool IsEn_Us { get; } diff --git a/IRaCIS.Core.Infra.EFCore/AuthUser/UserInfo.cs b/IRaCIS.Core.Infra.EFCore/AuthUser/UserInfo.cs index 2fe9e9723..f998fe11b 100644 --- a/IRaCIS.Core.Infra.EFCore/AuthUser/UserInfo.cs +++ b/IRaCIS.Core.Infra.EFCore/AuthUser/UserInfo.cs @@ -169,7 +169,7 @@ namespace IRaCIS.Core.Domain.Share } } - public bool IsTestUser + public bool IsTestUser { get { @@ -227,7 +227,17 @@ namespace IRaCIS.Core.Domain.Share get { - return _accessor?.HttpContext.GetClientIP(); + return _accessor?.HttpContext?.GetClientIP() ?? string.Empty; + } + } + + + public string Domain + { + get + { + + return _accessor?.HttpContext?.Request.Host.Host ?? string.Empty; } } @@ -237,7 +247,7 @@ namespace IRaCIS.Core.Domain.Share get { - return _accessor?.HttpContext?.Connection.LocalIpAddress.MapToIPv4().ToString(); + return _accessor?.HttpContext?.Connection?.LocalIpAddress?.MapToIPv4().ToString() ?? string.Empty; } } From 121ca01be35c3c87d45fbf342353c72c4a9b15ca Mon Sep 17 00:00:00 2001 From: hang <872297557@qq.com> Date: Wed, 11 Mar 2026 17:00:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs | 2 +- IRaCIS.Core.API/HostService/SyncFileRecoveryService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs b/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs index a14ef5c3c..894d36696 100644 --- a/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs +++ b/IRC.Core.SCP/HostConfig/SyncFileRecoveryService.cs @@ -104,7 +104,7 @@ public class FileSyncWorker(IServiceScopeFactory _scopeFactory, ILogger Date: Wed, 11 Mar 2026 17:25:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=E9=98=9F?= =?UTF-8?q?=E5=88=97=E6=B3=A8=E5=86=8C=E4=B8=BA=E5=8D=95=E4=BE=8B=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IRC.Core.SCP/Program.cs | 1 + IRaCIS.Core.API/Progranm.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/IRC.Core.SCP/Program.cs b/IRC.Core.SCP/Program.cs index bd306d5b8..db4c1450e 100644 --- a/IRC.Core.SCP/Program.cs +++ b/IRC.Core.SCP/Program.cs @@ -63,6 +63,7 @@ builder.Host #region 配置服务 var _configuration = builder.Configuration; +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/IRaCIS.Core.API/Progranm.cs b/IRaCIS.Core.API/Progranm.cs index 2567eba24..362f88c60 100644 --- a/IRaCIS.Core.API/Progranm.cs +++ b/IRaCIS.Core.API/Progranm.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -88,6 +89,7 @@ builder.Services.ConfigureServices(_configuration); builder.Services.AddHostedService(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService();