1111
This commit is contained in:
-3
@@ -362,9 +362,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
|||||||
await _db.Deleteable<ObOnboardingTemplateMaterial>()
|
await _db.Deleteable<ObOnboardingTemplateMaterial>()
|
||||||
.Where(item => item.OnboardingTemplateId == templateId)
|
.Where(item => item.OnboardingTemplateId == templateId)
|
||||||
.ExecuteCommandAsync();
|
.ExecuteCommandAsync();
|
||||||
await _db.Deleteable<ObOnboardingTemplateFormField>()
|
|
||||||
.Where(item => item.OnboardingTemplateId == templateId)
|
|
||||||
.ExecuteCommandAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<OnboardingTemplateOutput>> BuildOutputs(List<ObOnboardingTemplate> templates)
|
private async Task<List<OnboardingTemplateOutput>> BuildOutputs(List<ObOnboardingTemplate> templates)
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ namespace SimpleAdmin.System;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class FileService : DbRepository<SysFile>, IFileService
|
public class FileService : DbRepository<SysFile>, IFileService
|
||||||
{
|
{
|
||||||
|
private static readonly HttpClient OssPublicHttpClient = new();
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
|
private readonly IAliyunOssClientProvider _ossClientProvider;
|
||||||
|
|
||||||
public FileService(IConfigService configService)
|
public FileService(
|
||||||
|
IConfigService configService,
|
||||||
|
IAliyunOssClientProvider ossClientProvider)
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_ossClientProvider = ossClientProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -258,7 +263,7 @@ public class FileService : DbRepository<SysFile>, IFileService
|
|||||||
metadata.ContentType = file.ContentType;
|
metadata.ContentType = file.ContentType;
|
||||||
|
|
||||||
await using var stream = file.OpenReadStream();
|
await using var stream = file.OpenReadStream();
|
||||||
var client = CreateOssClient(setting);
|
var client = await _ossClientProvider.GetClientAsync();
|
||||||
client.PutObject(setting.Bucket, objectName, stream, metadata);
|
client.PutObject(setting.Bucket, objectName, stream, metadata);
|
||||||
|
|
||||||
var downloadUrl = string.IsNullOrWhiteSpace(setting.OSSUrl)
|
var downloadUrl = string.IsNullOrWhiteSpace(setting.OSSUrl)
|
||||||
@@ -274,22 +279,63 @@ public class FileService : DbRepository<SysFile>, IFileService
|
|||||||
private async Task<FileStreamResult> GetFileStreamResultFromAliyunOss(SysFile file)
|
private async Task<FileStreamResult> GetFileStreamResultFromAliyunOss(SysFile file)
|
||||||
{
|
{
|
||||||
var setting = GetOssSetting();
|
var setting = GetOssSetting();
|
||||||
var client = CreateOssClient(setting);
|
try
|
||||||
|
{
|
||||||
|
var client = await _ossClientProvider.GetClientAsync();
|
||||||
var ossObject = client.GetObject(file.Bucket, file.ObjName);
|
var ossObject = client.GetObject(file.Bucket, file.ObjName);
|
||||||
var memory = new MemoryStream();
|
|
||||||
await using (ossObject.Content)
|
await using (ossObject.Content)
|
||||||
{
|
{
|
||||||
await ossObject.Content.CopyToAsync(memory);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将远程文件流复制到独立内存流,避免远程响应释放后影响文件下载。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<FileStreamResult> BuildFileStreamResult(
|
||||||
|
Stream source,
|
||||||
|
string originalFileName)
|
||||||
|
{
|
||||||
|
var memory = new MemoryStream();
|
||||||
|
await source.CopyToAsync(memory);
|
||||||
memory.Position = 0;
|
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")
|
return new FileStreamResult(memory, "application/octet-stream")
|
||||||
{
|
{
|
||||||
FileDownloadName = fileName
|
FileDownloadName = fileName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据受信任的 OSS 配置生成公开对象地址,不使用数据库中的任意外部地址。
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取并校验阿里云 OSS 配置。
|
/// 读取并校验阿里云 OSS 配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -307,47 +353,6 @@ public class FileService : DbRepository<SysFile>, IFileService
|
|||||||
return setting;
|
return setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 通过 STS 临时凭证创建阿里云 OSS 客户端。
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取文件大小
|
/// 获取文件大小
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using Aliyun.Credentials.Models;
|
||||||
|
using Aliyun.OSS;
|
||||||
|
using CredentialClient = Aliyun.Credentials.Client;
|
||||||
|
|
||||||
|
namespace SimpleAdmin.System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在单机进程内缓存阿里云 STS 凭证及 OSS 客户端。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AliyunOssClientProvider : IAliyunOssClientProvider, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// STS 临时凭证有效期,单位为秒。
|
||||||
|
/// </summary>
|
||||||
|
private const int RoleSessionExpirationSeconds = 3600;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单次刷新失败时的最大尝试次数。
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxRefreshAttempts = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在凭证到期前提前刷新,官方凭证客户端会继续维护其内部缓存。
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan RefreshAhead = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 防止多个上传或下载请求同时刷新临时凭证。
|
||||||
|
/// </summary>
|
||||||
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||||
|
|
||||||
|
private readonly ILogger<AliyunOssClientProvider> _logger;
|
||||||
|
private readonly OssSettingOptions _setting;
|
||||||
|
private readonly string _endpoint;
|
||||||
|
private readonly CredentialClient _credentialClient;
|
||||||
|
private OssClient _cachedClient;
|
||||||
|
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public AliyunOssClientProvider(ILogger<AliyunOssClientProvider> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<OssClient> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取凭证时执行有限重试,避免一次瞬时 TLS 抖动直接中断业务。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<CredentialModel> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将官方凭证返回的 Unix 时间戳转换为到期时间。
|
||||||
|
/// </summary>
|
||||||
|
private static DateTimeOffset ResolveExpiration(long expiration)
|
||||||
|
{
|
||||||
|
if (expiration <= 0)
|
||||||
|
return DateTimeOffset.UtcNow.AddMinutes(30);
|
||||||
|
|
||||||
|
return expiration > 253402300799
|
||||||
|
? DateTimeOffset.FromUnixTimeMilliseconds(expiration)
|
||||||
|
: DateTimeOffset.FromUnixTimeSeconds(expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读取并校验创建客户端所需的配置。
|
||||||
|
/// </summary>
|
||||||
|
private static OssSettingOptions GetAndValidateSetting()
|
||||||
|
{
|
||||||
|
var setting = App.GetConfig<OssSettingOptions>("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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统一补充 OSS Endpoint 的 HTTPS 协议。
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SimpleAdmin.System;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供可复用的阿里云 OSS 客户端。
|
||||||
|
/// </summary>
|
||||||
|
public interface IAliyunOssClientProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取使用当前有效凭证创建的 OSS 客户端。
|
||||||
|
/// </summary>
|
||||||
|
Task<Aliyun.OSS.OssClient> GetClientAsync();
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AlibabaCloud.SDK.Sts20150401" Version="1.2.0"/>
|
<PackageReference Include="Aliyun.Credentials" Version="1.5.3"/>
|
||||||
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.0"/>
|
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.0"/>
|
||||||
<PackageReference Include="IP2Region.Net" Version="2.0.2"/>
|
<PackageReference Include="IP2Region.Net" Version="2.0.2"/>
|
||||||
<PackageReference Include="UAParser" Version="3.1.47"/>
|
<PackageReference Include="UAParser" Version="3.1.47"/>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public class Startup : AppStartup
|
|||||||
{
|
{
|
||||||
// 配置ip2region
|
// 配置ip2region
|
||||||
services.AddSingleton<ISearcher>(new Searcher(CachePolicy.Content, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ip2region.xdb")));
|
services.AddSingleton<ISearcher>(new Searcher(CachePolicy.Content, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ip2region.xdb")));
|
||||||
|
// 单机进程内统一缓存并刷新 OSS STS 临时凭证。
|
||||||
|
services.AddSingleton<IAliyunOssClientProvider, AliyunOssClientProvider>();
|
||||||
|
|
||||||
//系统配置转实体
|
//系统配置转实体
|
||||||
services.AddConfigurableOptions<SystemSettingsOptions>();
|
services.AddConfigurableOptions<SystemSettingsOptions>();
|
||||||
|
|||||||
@@ -32,10 +32,10 @@
|
|||||||
"RoleSessionName": "onboardx-server",
|
"RoleSessionName": "onboardx-server",
|
||||||
"StsEndpoint": "sts.cn-beijing.aliyuncs.com",
|
"StsEndpoint": "sts.cn-beijing.aliyuncs.com",
|
||||||
"Bucket": "onboardx",
|
"Bucket": "onboardx",
|
||||||
"EndPoint": "oss-cn-beijing.aliyuncs.com",
|
"EndPoint": "cn-beijing.oss.aliyuncs.com",
|
||||||
"IMMEndpoint": "",
|
"IMMEndpoint": "",
|
||||||
"IMMProject": "",
|
"IMMProject": "",
|
||||||
"OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com",
|
"OSSUrl": "https://onboardx.cn-beijing.oss.aliyuncs.com",
|
||||||
"RegionId": "oss-cn-beijing"
|
"RegionId": "oss-cn-beijing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
"RoleSessionName": "onboardx-server",
|
"RoleSessionName": "onboardx-server",
|
||||||
"StsEndpoint": "sts.cn-beijing.aliyuncs.com",
|
"StsEndpoint": "sts.cn-beijing.aliyuncs.com",
|
||||||
"Bucket": "onboardx",
|
"Bucket": "onboardx",
|
||||||
"EndPoint": "oss-cn-beijing.aliyuncs.com",
|
"EndPoint": "cn-beijing.oss.aliyuncs.com",
|
||||||
"IMMEndpoint": "",
|
"IMMEndpoint": "",
|
||||||
"IMMProject": "",
|
"IMMProject": "",
|
||||||
"OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com",
|
"OSSUrl": "https://onboardx.cn-beijing.oss.aliyuncs.com",
|
||||||
"RegionId": "oss-cn-beijing"
|
"RegionId": "oss-cn-beijing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user