feat: finalize core modules and gateway
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Aliyun.OSS;
|
||||
using Aliyun.OSS.Util;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 存储提供商实现。
|
||||
/// </summary>
|
||||
public sealed class AliyunOssStorageProvider(IOptionsMonitor<StorageOptions> optionsMonitor) : IObjectStorageProvider, IDisposable
|
||||
{
|
||||
private OssClient? _client;
|
||||
private bool _disposed;
|
||||
|
||||
private StorageOptions CurrentOptions => optionsMonitor.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public StorageProviderKind Kind => StorageProviderKind.AliyunOss;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = CurrentOptions;
|
||||
var metadata = new ObjectMetadata
|
||||
{
|
||||
ContentLength = request.ContentLength,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
|
||||
foreach (var kv in request.Metadata)
|
||||
{
|
||||
metadata.UserMetadata[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
// Aliyun OSS SDK 支持异步方法,如未支持将同步封装为任务。
|
||||
await PutObjectAsync(options.AliyunOss.Bucket, request.ObjectKey, request.Content, metadata, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var signedUrl = request.GenerateSignedUrl
|
||||
? await GenerateDownloadUrlAsync(request.ObjectKey, request.SignedUrlExpires, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
return new StorageUploadResult
|
||||
{
|
||||
ObjectKey = request.ObjectKey,
|
||||
Url = signedUrl ?? BuildPublicUrl(request.ObjectKey),
|
||||
SignedUrl = signedUrl,
|
||||
FileSize = request.ContentLength,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires);
|
||||
var uploadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Put, request.ContentType);
|
||||
var downloadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Get, null);
|
||||
|
||||
var result = new StorageDirectUploadResult
|
||||
{
|
||||
UploadUrl = uploadUrl,
|
||||
FormFields = new Dictionary<string, string>(),
|
||||
ExpiresAt = expiresAt,
|
||||
ObjectKey = request.ObjectKey,
|
||||
SignedDownloadUrl = downloadUrl
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = GeneratePresignedUrl(objectKey, expires, SignHttpMethod.Get, null);
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildPublicUrl(string objectKey)
|
||||
{
|
||||
var cdn = CurrentOptions.AliyunOss.CdnBaseUrl ?? CurrentOptions.CdnBaseUrl;
|
||||
if (!string.IsNullOrWhiteSpace(cdn))
|
||||
{
|
||||
return $"{cdn!.TrimEnd('/')}/{objectKey}";
|
||||
}
|
||||
|
||||
var endpoint = CurrentOptions.AliyunOss.Endpoint.TrimEnd('/');
|
||||
var scheme = CurrentOptions.AliyunOss.UseHttps ? "https" : "http";
|
||||
// Endpoint 可能已包含协议,若没有则补充。
|
||||
if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
endpoint = $"{scheme}://{endpoint}";
|
||||
}
|
||||
|
||||
return $"{endpoint}/{CurrentOptions.AliyunOss.Bucket}/{objectKey}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传对象到 OSS。
|
||||
/// </summary>
|
||||
private async Task PutObjectAsync(string bucket, string key, Stream content, ObjectMetadata metadata, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = EnsureClient();
|
||||
await Task.Run(() => client.PutObject(bucket, key, content, metadata), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成预签名 URL。
|
||||
/// </summary>
|
||||
private string GeneratePresignedUrl(string objectKey, TimeSpan expires, SignHttpMethod method, string? contentType)
|
||||
{
|
||||
var request = new GeneratePresignedUriRequest(CurrentOptions.AliyunOss.Bucket, objectKey, method)
|
||||
{
|
||||
Expiration = DateTime.Now.Add(expires)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
request.ContentType = contentType;
|
||||
}
|
||||
|
||||
var uri = EnsureClient().GeneratePresignedUri(request);
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建或复用 OSS 客户端。
|
||||
/// </summary>
|
||||
private OssClient EnsureClient()
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
return _client;
|
||||
}
|
||||
|
||||
var options = CurrentOptions.AliyunOss;
|
||||
_client = new OssClient(options.Endpoint, options.AccessKeyId, options.AccessKeySecret);
|
||||
return _client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 七牛云 Kodo(S3 兼容网关)存储提供商。
|
||||
/// </summary>
|
||||
public sealed class QiniuKodoStorageProvider(IOptionsMonitor<StorageOptions> optionsMonitor)
|
||||
: S3StorageProviderBase
|
||||
{
|
||||
private StorageOptions CurrentOptions => optionsMonitor.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override StorageProviderKind Kind => StorageProviderKind.QiniuKodo;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string Bucket => CurrentOptions.QiniuKodo.Bucket;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.Endpoint)
|
||||
? $"{(CurrentOptions.QiniuKodo.UseHttps ? "https" : "http")}://s3.qiniucs.com"
|
||||
: CurrentOptions.QiniuKodo.Endpoint!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AccessKey => CurrentOptions.QiniuKodo.AccessKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string SecretKey => CurrentOptions.QiniuKodo.SecretKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool UseHttps => CurrentOptions.QiniuKodo.UseHttps;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ForcePathStyle => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.DownloadDomain)
|
||||
? CurrentOptions.QiniuKodo.DownloadDomain
|
||||
: CurrentOptions.CdnBaseUrl;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override TimeSpan SignedUrlExpiry
|
||||
{
|
||||
get
|
||||
{
|
||||
var minutes = CurrentOptions.QiniuKodo.SignedUrlExpirationMinutes
|
||||
?? CurrentOptions.Security.DefaultUrlExpirationMinutes;
|
||||
return TimeSpan.FromMinutes(Math.Max(1, minutes));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using TakeoutSaaS.Module.Storage.Abstractions;
|
||||
using TakeoutSaaS.Module.Storage.Models;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 AWS S3 SDK 的通用存储提供商基类,可复用到 COS 与 Kodo 等兼容实现。
|
||||
/// </summary>
|
||||
public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposable
|
||||
{
|
||||
private IAmazonS3? _client;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract StorageProviderKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标桶名称。
|
||||
/// </summary>
|
||||
protected abstract string Bucket { get; }
|
||||
|
||||
/// <summary>
|
||||
/// S3 服务端点,需包含协议。
|
||||
/// </summary>
|
||||
protected abstract string ServiceUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问凭证 ID。
|
||||
/// </summary>
|
||||
protected abstract string AccessKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问凭证密钥。
|
||||
/// </summary>
|
||||
protected abstract string SecretKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用 HTTPS。
|
||||
/// </summary>
|
||||
protected abstract bool UseHttps { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制 PathStyle 访问。
|
||||
/// </summary>
|
||||
protected abstract bool ForcePathStyle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// CDN 域名(可选)。
|
||||
/// </summary>
|
||||
protected abstract string? CdnBaseUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 默认签名有效期。
|
||||
/// </summary>
|
||||
protected abstract TimeSpan SignedUrlExpiry { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var putRequest = new PutObjectRequest
|
||||
{
|
||||
BucketName = Bucket,
|
||||
Key = request.ObjectKey,
|
||||
InputStream = request.Content,
|
||||
AutoCloseStream = false,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
|
||||
foreach (var kv in request.Metadata)
|
||||
{
|
||||
putRequest.Metadata[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
await Client.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var signedUrl = request.GenerateSignedUrl
|
||||
? GenerateSignedUrl(request.ObjectKey, request.SignedUrlExpires)
|
||||
: null;
|
||||
|
||||
return new StorageUploadResult
|
||||
{
|
||||
ObjectKey = request.ObjectKey,
|
||||
Url = signedUrl ?? BuildPublicUrl(request.ObjectKey),
|
||||
SignedUrl = signedUrl,
|
||||
FileSize = request.ContentLength,
|
||||
ContentType = request.ContentType
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires);
|
||||
var uploadUrl = GenerateSignedUrl(request.ObjectKey, request.Expires, HttpVerb.PUT, request.ContentType);
|
||||
var signedDownload = GenerateSignedUrl(request.ObjectKey, request.Expires);
|
||||
|
||||
var result = new StorageDirectUploadResult
|
||||
{
|
||||
UploadUrl = uploadUrl,
|
||||
FormFields = new Dictionary<string, string>(),
|
||||
ExpiresAt = expiresAt,
|
||||
ObjectKey = request.ObjectKey,
|
||||
SignedDownloadUrl = signedDownload
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = GenerateSignedUrl(objectKey, expires);
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string BuildPublicUrl(string objectKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CdnBaseUrl))
|
||||
{
|
||||
return $"{CdnBaseUrl!.TrimEnd('/')}/{objectKey}";
|
||||
}
|
||||
|
||||
var endpoint = new Uri(ServiceUrl);
|
||||
var scheme = UseHttps ? "https" : "http";
|
||||
return $"{scheme}://{Bucket}.{endpoint.Host}/{objectKey}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成预签名 URL。
|
||||
/// </summary>
|
||||
/// <param name="objectKey">对象键。</param>
|
||||
/// <param name="expires">过期时间。</param>
|
||||
/// <param name="verb">HTTP 动作。</param>
|
||||
/// <param name="contentType">可选的内容类型约束。</param>
|
||||
protected virtual string GenerateSignedUrl(string objectKey, TimeSpan expires, HttpVerb verb = HttpVerb.GET, string? contentType = null)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = Bucket,
|
||||
Key = objectKey,
|
||||
Verb = verb,
|
||||
Expires = DateTime.UtcNow.Add(expires),
|
||||
Protocol = UseHttps ? Protocol.HTTPS : Protocol.HTTP
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
request.Headers["Content-Type"] = contentType;
|
||||
}
|
||||
|
||||
return Client.GetPreSignedURL(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建 S3 客户端。
|
||||
/// </summary>
|
||||
protected virtual IAmazonS3 CreateClient()
|
||||
{
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
ServiceURL = ServiceUrl,
|
||||
ForcePathStyle = ForcePathStyle,
|
||||
UseHttp = !UseHttps,
|
||||
SignatureVersion = "4"
|
||||
};
|
||||
|
||||
var credentials = new BasicAWSCredentials(AccessKey, SecretKey);
|
||||
return new AmazonS3Client(credentials, config);
|
||||
}
|
||||
|
||||
private IAmazonS3 Client => _client ??= CreateClient();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_client?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Module.Storage.Options;
|
||||
|
||||
namespace TakeoutSaaS.Module.Storage.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯云 COS 存储提供商实现。
|
||||
/// </summary>
|
||||
public sealed class TencentCosStorageProvider(IOptionsMonitor<StorageOptions> optionsMonitor)
|
||||
: S3StorageProviderBase
|
||||
{
|
||||
private StorageOptions CurrentOptions => optionsMonitor.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override StorageProviderKind Kind => StorageProviderKind.TencentCos;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string Bucket => CurrentOptions.TencentCos.Bucket;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.Endpoint)
|
||||
? $"{(CurrentOptions.TencentCos.UseHttps ? "https" : "http")}://cos.{CurrentOptions.TencentCos.Region}.myqcloud.com"
|
||||
: CurrentOptions.TencentCos.Endpoint!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string AccessKey => CurrentOptions.TencentCos.SecretId;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string SecretKey => CurrentOptions.TencentCos.SecretKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool UseHttps => CurrentOptions.TencentCos.UseHttps;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ForcePathStyle => CurrentOptions.TencentCos.ForcePathStyle;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.CdnBaseUrl)
|
||||
? CurrentOptions.TencentCos.CdnBaseUrl
|
||||
: CurrentOptions.CdnBaseUrl;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override TimeSpan SignedUrlExpiry =>
|
||||
TimeSpan.FromMinutes(Math.Max(1, CurrentOptions.Security.DefaultUrlExpirationMinutes));
|
||||
}
|
||||
Reference in New Issue
Block a user