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(); } }