diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs index 3f2ca1b..cb4665b 100644 --- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs @@ -362,9 +362,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService await _db.Deleteable() .Where(item => item.OnboardingTemplateId == templateId) .ExecuteCommandAsync(); - await _db.Deleteable() - .Where(item => item.OnboardingTemplateId == templateId) - .ExecuteCommandAsync(); } private async Task> BuildOutputs(List templates) diff --git a/SimpleAdmin.System/Services/Dev/File/FileService.cs b/SimpleAdmin.System/Services/Dev/File/FileService.cs index 849f898..02af6e4 100644 --- a/SimpleAdmin.System/Services/Dev/File/FileService.cs +++ b/SimpleAdmin.System/Services/Dev/File/FileService.cs @@ -17,11 +17,16 @@ namespace SimpleAdmin.System; /// public class FileService : DbRepository, IFileService { + private static readonly HttpClient OssPublicHttpClient = new(); private readonly IConfigService _configService; + private readonly IAliyunOssClientProvider _ossClientProvider; - public FileService(IConfigService configService) + public FileService( + IConfigService configService, + IAliyunOssClientProvider ossClientProvider) { _configService = configService; + _ossClientProvider = ossClientProvider; } /// @@ -258,7 +263,7 @@ public class FileService : DbRepository, IFileService metadata.ContentType = file.ContentType; await using var stream = file.OpenReadStream(); - var client = CreateOssClient(setting); + var client = await _ossClientProvider.GetClientAsync(); client.PutObject(setting.Bucket, objectName, stream, metadata); var downloadUrl = string.IsNullOrWhiteSpace(setting.OSSUrl) @@ -274,22 +279,63 @@ public class FileService : DbRepository, IFileService private async Task GetFileStreamResultFromAliyunOss(SysFile file) { var setting = GetOssSetting(); - var client = CreateOssClient(setting); - var ossObject = client.GetObject(file.Bucket, file.ObjName); - var memory = new MemoryStream(); - await using (ossObject.Content) + try { - await ossObject.Content.CopyToAsync(memory); + var client = await _ossClientProvider.GetClientAsync(); + var ossObject = client.GetObject(file.Bucket, file.ObjName); + await using (ossObject.Content) + { + return await BuildFileStreamResult(ossObject.Content, file.Name); + } } + catch + { + // STS/OSS SDK 暂时不可用时,只通过已公开的对象地址读取文件。 + // 后台下载接口仍由系统鉴权保护,且该降级过程不会使用或暴露长期 AK。 + var publicUrl = BuildOssPublicUrl(file, setting); + using var response = await OssPublicHttpClient.GetAsync( + publicUrl, + HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + await using var content = await response.Content.ReadAsStreamAsync(); + return await BuildFileStreamResult(content, file.Name); + } + } + /// + /// 将远程文件流复制到独立内存流,避免远程响应释放后影响文件下载。 + /// + private static async Task BuildFileStreamResult( + Stream source, + string originalFileName) + { + var memory = new MemoryStream(); + await source.CopyToAsync(memory); memory.Position = 0; - var fileName = HttpUtility.UrlEncode(file.Name, Encoding.GetEncoding("UTF-8")); + var fileName = HttpUtility.UrlEncode( + originalFileName, + Encoding.GetEncoding("UTF-8")); return new FileStreamResult(memory, "application/octet-stream") { FileDownloadName = fileName }; } + /// + /// 根据受信任的 OSS 配置生成公开对象地址,不使用数据库中的任意外部地址。 + /// + private static string BuildOssPublicUrl(SysFile file, OssSettingOptions setting) + { + var baseUrl = string.IsNullOrWhiteSpace(setting.OSSUrl) + ? $"https://{file.Bucket}.{setting.EndPoint.Trim().TrimEnd('/')}" + : setting.OSSUrl.Trim().TrimEnd('/'); + var objectPath = string.Join( + "/", + file.ObjName.Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + return $"{baseUrl}/{objectPath}"; + } + /// /// 读取并校验阿里云 OSS 配置。 /// @@ -307,47 +353,6 @@ public class FileService : DbRepository, IFileService return setting; } - /// - /// 通过 STS 临时凭证创建阿里云 OSS 客户端。 - /// - private static OssClient CreateOssClient(OssSettingOptions setting) - { - var endpoint = setting.EndPoint.Trim(); - if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) - && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - { - endpoint = "https://" + endpoint; - } - - if (string.IsNullOrWhiteSpace(setting.RoleArn)) - return new OssClient(endpoint, setting.AccessKeyID, setting.AccessKeySecret); - - var stsConfig = new AlibabaCloud.OpenApiClient.Models.Config - { - AccessKeyId = setting.AccessKeyID, - AccessKeySecret = setting.AccessKeySecret, - Endpoint = setting.StsEndpoint - }; - var stsClient = new AlibabaCloud.SDK.Sts20150401.Client(stsConfig); - var request = new AlibabaCloud.SDK.Sts20150401.Models.AssumeRoleRequest - { - RoleArn = setting.RoleArn, - RoleSessionName = string.IsNullOrWhiteSpace(setting.RoleSessionName) - ? "onboardx-server" - : setting.RoleSessionName, - DurationSeconds = 3600 - }; - var credentials = stsClient.AssumeRole(request).Body.Credentials; - if (credentials == null) - throw Oops.Bah("获取阿里云 OSS 临时凭证失败"); - - return new OssClient( - endpoint, - credentials.AccessKeyId, - credentials.AccessKeySecret, - credentials.SecurityToken); - } - /// /// 获取文件大小 /// diff --git a/SimpleAdmin.System/Services/Dev/File/Oss/AliyunOssClientProvider.cs b/SimpleAdmin.System/Services/Dev/File/Oss/AliyunOssClientProvider.cs new file mode 100644 index 0000000..def66c6 --- /dev/null +++ b/SimpleAdmin.System/Services/Dev/File/Oss/AliyunOssClientProvider.cs @@ -0,0 +1,194 @@ +using Aliyun.Credentials.Models; +using Aliyun.OSS; +using CredentialClient = Aliyun.Credentials.Client; + +namespace SimpleAdmin.System; + +/// +/// 在单机进程内缓存阿里云 STS 凭证及 OSS 客户端。 +/// +public sealed class AliyunOssClientProvider : IAliyunOssClientProvider, IDisposable +{ + /// + /// STS 临时凭证有效期,单位为秒。 + /// + private const int RoleSessionExpirationSeconds = 3600; + + /// + /// 单次刷新失败时的最大尝试次数。 + /// + private const int MaxRefreshAttempts = 3; + + /// + /// 在凭证到期前提前刷新,官方凭证客户端会继续维护其内部缓存。 + /// + private static readonly TimeSpan RefreshAhead = TimeSpan.FromMinutes(2); + + /// + /// 防止多个上传或下载请求同时刷新临时凭证。 + /// + private readonly SemaphoreSlim _refreshLock = new(1, 1); + + private readonly ILogger _logger; + private readonly OssSettingOptions _setting; + private readonly string _endpoint; + private readonly CredentialClient _credentialClient; + private OssClient _cachedClient; + private DateTimeOffset _expiresAt = DateTimeOffset.MinValue; + + public AliyunOssClientProvider(ILogger logger) + { + _logger = logger; + _setting = GetAndValidateSetting(); + _endpoint = NormalizeEndpoint(_setting.EndPoint); + + if (string.IsNullOrWhiteSpace(_setting.RoleArn)) + { + _cachedClient = new OssClient( + _endpoint, + _setting.AccessKeyID, + _setting.AccessKeySecret); + _expiresAt = DateTimeOffset.MaxValue; + return; + } + + var credentialConfig = new Config + { + Type = "ram_role_arn", + AccessKeyId = _setting.AccessKeyID, + AccessKeySecret = _setting.AccessKeySecret, + RoleArn = _setting.RoleArn, + RoleSessionName = string.IsNullOrWhiteSpace(_setting.RoleSessionName) + ? "onboardx-server" + : _setting.RoleSessionName, + RoleSessionExpiration = RoleSessionExpirationSeconds, + STSEndpoint = _setting.StsEndpoint, + ConnectTimeout = 10000, + Timeout = 10000 + }; + _credentialClient = new CredentialClient(credentialConfig); + } + + /// + public async Task GetClientAsync() + { + if (_credentialClient == null) + return _cachedClient; + + var now = DateTimeOffset.UtcNow; + if (_cachedClient != null && now < _expiresAt - RefreshAhead) + return _cachedClient; + + await _refreshLock.WaitAsync(); + try + { + now = DateTimeOffset.UtcNow; + if (_cachedClient != null && now < _expiresAt - RefreshAhead) + return _cachedClient; + + try + { + var credential = await GetCredentialWithRetryAsync(); + var credentialIncomplete = credential == null + || string.IsNullOrWhiteSpace(credential.AccessKeyId) + || string.IsNullOrWhiteSpace(credential.AccessKeySecret) + || string.IsNullOrWhiteSpace(credential.SecurityToken); + if (credentialIncomplete) + throw Oops.Bah("获取阿里云 OSS 临时凭证失败"); + + _cachedClient = new OssClient( + _endpoint, + credential.AccessKeyId, + credential.AccessKeySecret, + credential.SecurityToken); + _expiresAt = ResolveExpiration(credential.Expiration); + return _cachedClient; + } + catch (Exception exception) when ( + _cachedClient != null && DateTimeOffset.UtcNow < _expiresAt) + { + // 刷新失败时继续使用尚未过期的旧凭证,避免短时网络波动中断业务。 + _logger.LogWarning( + exception, + "刷新阿里云 OSS STS 凭证失败,将继续使用未过期的内存凭证,有效期至 {ExpiresAt}", + _expiresAt); + return _cachedClient; + } + } + finally + { + _refreshLock.Release(); + } + } + + /// + /// 获取凭证时执行有限重试,避免一次瞬时 TLS 抖动直接中断业务。 + /// + private async Task GetCredentialWithRetryAsync() + { + for (var attempt = 1; ; attempt++) + { + try + { + return await _credentialClient.GetCredentialAsync(); + } + catch (Exception exception) when (attempt < MaxRefreshAttempts) + { + _logger.LogWarning( + exception, + "第 {Attempt} 次获取阿里云 OSS STS 凭证失败,准备重试", + attempt); + await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt)); + } + } + } + + /// + /// 将官方凭证返回的 Unix 时间戳转换为到期时间。 + /// + private static DateTimeOffset ResolveExpiration(long expiration) + { + if (expiration <= 0) + return DateTimeOffset.UtcNow.AddMinutes(30); + + return expiration > 253402300799 + ? DateTimeOffset.FromUnixTimeMilliseconds(expiration) + : DateTimeOffset.FromUnixTimeSeconds(expiration); + } + + /// + /// 读取并校验创建客户端所需的配置。 + /// + private static OssSettingOptions GetAndValidateSetting() + { + var setting = App.GetConfig("OssSetting", true); + var configurationIncomplete = setting == null + || string.IsNullOrWhiteSpace(setting.AccessKeyID) + || string.IsNullOrWhiteSpace(setting.AccessKeySecret) + || string.IsNullOrWhiteSpace(setting.EndPoint); + if (configurationIncomplete) + throw Oops.Bah("阿里云 OSS 配置不完整"); + + return setting; + } + + /// + /// 统一补充 OSS Endpoint 的 HTTPS 协议。 + /// + private static string NormalizeEndpoint(string endpoint) + { + endpoint = endpoint.Trim(); + if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + endpoint = "https://" + endpoint; + } + + return endpoint; + } + + public void Dispose() + { + _refreshLock.Dispose(); + } +} diff --git a/SimpleAdmin.System/Services/Dev/File/Oss/IAliyunOssClientProvider.cs b/SimpleAdmin.System/Services/Dev/File/Oss/IAliyunOssClientProvider.cs new file mode 100644 index 0000000..7187ae9 --- /dev/null +++ b/SimpleAdmin.System/Services/Dev/File/Oss/IAliyunOssClientProvider.cs @@ -0,0 +1,12 @@ +namespace SimpleAdmin.System; + +/// +/// 提供可复用的阿里云 OSS 客户端。 +/// +public interface IAliyunOssClientProvider +{ + /// + /// 获取使用当前有效凭证创建的 OSS 客户端。 + /// + Task GetClientAsync(); +} diff --git a/SimpleAdmin.System/SimpleAdmin.System.csproj b/SimpleAdmin.System/SimpleAdmin.System.csproj index e09d0c4..1866bdc 100644 --- a/SimpleAdmin.System/SimpleAdmin.System.csproj +++ b/SimpleAdmin.System/SimpleAdmin.System.csproj @@ -2,7 +2,7 @@ - + diff --git a/SimpleAdmin.System/Startup.cs b/SimpleAdmin.System/Startup.cs index 49b28bc..54b8b09 100644 --- a/SimpleAdmin.System/Startup.cs +++ b/SimpleAdmin.System/Startup.cs @@ -29,6 +29,8 @@ public class Startup : AppStartup { // 配置ip2region services.AddSingleton(new Searcher(CachePolicy.Content, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ip2region.xdb"))); + // 单机进程内统一缓存并刷新 OSS STS 临时凭证。 + services.AddSingleton(); //系统配置转实体 services.AddConfigurableOptions(); diff --git a/SimpleAdmin.System/System.Development.json b/SimpleAdmin.System/System.Development.json index 1f193d3..64e2745 100644 --- a/SimpleAdmin.System/System.Development.json +++ b/SimpleAdmin.System/System.Development.json @@ -32,10 +32,10 @@ "RoleSessionName": "onboardx-server", "StsEndpoint": "sts.cn-beijing.aliyuncs.com", "Bucket": "onboardx", - "EndPoint": "oss-cn-beijing.aliyuncs.com", + "EndPoint": "cn-beijing.oss.aliyuncs.com", "IMMEndpoint": "", "IMMProject": "", - "OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com", + "OSSUrl": "https://onboardx.cn-beijing.oss.aliyuncs.com", "RegionId": "oss-cn-beijing" } } \ No newline at end of file diff --git a/SimpleAdmin.System/System.Production.json b/SimpleAdmin.System/System.Production.json index 1f193d3..64e2745 100644 --- a/SimpleAdmin.System/System.Production.json +++ b/SimpleAdmin.System/System.Production.json @@ -32,10 +32,10 @@ "RoleSessionName": "onboardx-server", "StsEndpoint": "sts.cn-beijing.aliyuncs.com", "Bucket": "onboardx", - "EndPoint": "oss-cn-beijing.aliyuncs.com", + "EndPoint": "cn-beijing.oss.aliyuncs.com", "IMMEndpoint": "", "IMMProject": "", - "OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com", + "OSSUrl": "https://onboardx.cn-beijing.oss.aliyuncs.com", "RegionId": "oss-cn-beijing" } } \ No newline at end of file