feat: finalize core modules and gateway

This commit is contained in:
2025-11-23 18:53:12 +08:00
parent 429d4fb747
commit ae273e510a
115 changed files with 4695 additions and 223 deletions

View File

@@ -0,0 +1,36 @@
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Module.Storage.Models;
namespace TakeoutSaaS.Module.Storage.Abstractions;
/// <summary>
/// 对象存储提供商统一抽象。
/// </summary>
public interface IObjectStorageProvider
{
/// <summary>
/// 当前提供商类型。
/// </summary>
StorageProviderKind Kind { get; }
/// <summary>
/// 上传文件到对象存储。
/// </summary>
Task<StorageUploadResult> UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 生成预签名直传参数PUT 或表单直传)。
/// </summary>
Task<StorageDirectUploadResult> CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 生成带过期时间的访问链接。
/// </summary>
Task<string> GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default);
/// <summary>
/// 生成公共访问地址(可结合 CDN
/// </summary>
string BuildPublicUrl(string objectKey);
}

View File

@@ -0,0 +1,14 @@
namespace TakeoutSaaS.Module.Storage.Abstractions;
/// <summary>
/// 存储提供商解析器,用于按需选择具体实现。
/// </summary>
public interface IStorageProviderResolver
{
/// <summary>
/// 根据配置解析出可用的存储提供商。
/// </summary>
/// <param name="provider">目标提供商类型,空则使用默认配置。</param>
/// <returns>对应的存储提供商。</returns>
IObjectStorageProvider Resolve(StorageProviderKind? provider = null);
}

View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Module.Storage.Abstractions;
using TakeoutSaaS.Module.Storage.Options;
using TakeoutSaaS.Module.Storage.Providers;
using TakeoutSaaS.Module.Storage.Services;
namespace TakeoutSaaS.Module.Storage.Extensions;
/// <summary>
/// 存储模块服务注册扩展。
/// </summary>
public static class StorageServiceCollectionExtensions
{
/// <summary>
/// 注册存储模块所需的提供商与配置。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
public static IServiceCollection AddStorageModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<StorageOptions>()
.Bind(configuration.GetSection("Storage"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IObjectStorageProvider, TencentCosStorageProvider>();
services.AddSingleton<IObjectStorageProvider, QiniuKodoStorageProvider>();
services.AddSingleton<IObjectStorageProvider, AliyunOssStorageProvider>();
services.AddSingleton<IStorageProviderResolver, StorageProviderResolver>();
return services;
}
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Module.Storage.Models;
/// <summary>
/// 直传(预签名上传)请求参数。
/// </summary>
public sealed class StorageDirectUploadRequest
{
/// <summary>
/// 初始化请求。
/// </summary>
/// <param name="objectKey">对象键。</param>
/// <param name="contentType">内容类型。</param>
/// <param name="contentLength">内容长度。</param>
/// <param name="expires">签名有效期。</param>
public StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires)
{
ObjectKey = objectKey;
ContentType = contentType;
ContentLength = contentLength;
Expires = expires;
}
/// <summary>
/// 目标对象键。
/// </summary>
public string ObjectKey { get; }
/// <summary>
/// 内容类型。
/// </summary>
public string ContentType { get; }
/// <summary>
/// 内容长度。
/// </summary>
public long ContentLength { get; }
/// <summary>
/// 签名有效期。
/// </summary>
public TimeSpan Expires { get; }
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace TakeoutSaaS.Module.Storage.Models;
/// <summary>
/// 直传(预签名上传)结果。
/// </summary>
public sealed class StorageDirectUploadResult
{
/// <summary>
/// 预签名上传地址PUT 上传或表单地址)。
/// </summary>
public string UploadUrl { get; init; } = string.Empty;
/// <summary>
/// 直传附加字段如表单直传所需字段PUT 方式为空。
/// </summary>
public IReadOnlyDictionary<string, string> FormFields { get; init; } = new Dictionary<string, string>();
/// <summary>
/// 预签名过期时间。
/// </summary>
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// 关联的对象键。
/// </summary>
public string ObjectKey { get; init; } = string.Empty;
/// <summary>
/// 上传成功后可选的签名下载地址。
/// </summary>
public string? SignedDownloadUrl { get; init; }
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.IO;
namespace TakeoutSaaS.Module.Storage.Models;
/// <summary>
/// 对象存储上传请求参数。
/// </summary>
public sealed class StorageUploadRequest
{
/// <summary>
/// 初始化上传请求。
/// </summary>
/// <param name="objectKey">对象键(含路径)。</param>
/// <param name="content">文件流。</param>
/// <param name="contentType">内容类型。</param>
/// <param name="contentLength">内容长度。</param>
/// <param name="generateSignedUrl">是否返回签名访问链接。</param>
/// <param name="signedUrlExpires">签名有效期。</param>
/// <param name="metadata">附加元数据。</param>
public StorageUploadRequest(
string objectKey,
Stream content,
string contentType,
long contentLength,
bool generateSignedUrl,
TimeSpan signedUrlExpires,
IDictionary<string, string>? metadata = null)
{
ObjectKey = objectKey;
Content = content;
ContentType = contentType;
ContentLength = contentLength;
GenerateSignedUrl = generateSignedUrl;
SignedUrlExpires = signedUrlExpires;
Metadata = metadata == null
? new Dictionary<string, string>()
: new Dictionary<string, string>(metadata);
}
/// <summary>
/// 对象键。
/// </summary>
public string ObjectKey { get; }
/// <summary>
/// 文件流。
/// </summary>
public Stream Content { get; }
/// <summary>
/// 内容类型。
/// </summary>
public string ContentType { get; }
/// <summary>
/// 内容长度。
/// </summary>
public long ContentLength { get; }
/// <summary>
/// 是否需要签名访问链接。
/// </summary>
public bool GenerateSignedUrl { get; }
/// <summary>
/// 签名有效期。
/// </summary>
public TimeSpan SignedUrlExpires { get; }
/// <summary>
/// 元数据集合。
/// </summary>
public IReadOnlyDictionary<string, string> Metadata { get; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Module.Storage.Models;
/// <summary>
/// 上传结果信息。
/// </summary>
public sealed class StorageUploadResult
{
/// <summary>
/// 对象键。
/// </summary>
public string ObjectKey { get; init; } = string.Empty;
/// <summary>
/// 可访问的 URL可能已包含签名
/// </summary>
public string Url { get; init; } = string.Empty;
/// <summary>
/// 带过期时间的签名 URL若生成
/// </summary>
public string? SignedUrl { get; init; }
/// <summary>
/// 文件大小。
/// </summary>
public long FileSize { get; init; }
/// <summary>
/// 内容类型。
/// </summary>
public string ContentType { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Module.Storage.Options;
/// <summary>
/// 阿里云 OSS 访问配置。
/// </summary>
public sealed class AliyunOssOptions
{
/// <summary>
/// 访问密钥 ID。
/// </summary>
[Required]
public string AccessKeyId { get; set; } = string.Empty;
/// <summary>
/// 访问密钥 Secret。
/// </summary>
[Required]
public string AccessKeySecret { get; set; } = string.Empty;
/// <summary>
/// Endpoint如 https://oss-cn-hangzhou.aliyuncs.com。
/// </summary>
[Required]
[Url]
public string Endpoint { get; set; } = string.Empty;
/// <summary>
/// 目标存储桶名称。
/// </summary>
[Required]
public string Bucket { get; set; } = string.Empty;
/// <summary>
/// CDN 加速域名(可选)。
/// </summary>
[Url]
public string? CdnBaseUrl { get; set; }
/// <summary>
/// 是否默认使用 HTTPS。
/// </summary>
public bool UseHttps { get; set; } = true;
}

View File

@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Module.Storage.Options;
/// <summary>
/// 七牛云 Kodo S3 兼容网关配置。
/// </summary>
public sealed class QiniuKodoOptions
{
/// <summary>
/// AccessKey。
/// </summary>
[Required]
public string AccessKey { get; set; } = string.Empty;
/// <summary>
/// SecretKey。
/// </summary>
[Required]
public string SecretKey { get; set; } = string.Empty;
/// <summary>
/// 绑定的空间名称。
/// </summary>
[Required]
public string Bucket { get; set; } = string.Empty;
/// <summary>
/// 下载域名CDN 域名或测试域名),用于生成访问链接。
/// </summary>
[Url]
public string? DownloadDomain { get; set; }
/// <summary>
/// S3 兼容网关 Endpoint如 https://s3-cn-south-1.qiniucs.com为空则使用官方默认。
/// </summary>
[Url]
public string? Endpoint { get; set; }
/// <summary>
/// 是否使用 HTTPS。
/// </summary>
public bool UseHttps { get; set; } = true;
/// <summary>
/// 直传或下载时默认有效期(分钟),未设置时使用全局安全配置。
/// </summary>
[Range(1, 24 * 60)]
public int? SignedUrlExpirationMinutes { get; set; }
}

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Module.Storage.Options;
/// <summary>
/// 存储模块的统一配置项,决定默认提供商与全局安全策略。
/// </summary>
public sealed class StorageOptions
{
/// <summary>
/// 默认使用的存储提供商。
/// </summary>
public StorageProviderKind Provider { get; set; } = StorageProviderKind.TencentCos;
/// <summary>
/// CDN 访问域名(可选),若配置则优先使用 CDN 域名生成访问地址。
/// </summary>
[Url]
public string? CdnBaseUrl { get; set; }
/// <summary>
/// 腾讯云 COS 配置。
/// </summary>
[Required]
public TencentCosOptions TencentCos { get; set; } = new();
/// <summary>
/// 七牛云 Kodo 配置。
/// </summary>
[Required]
public QiniuKodoOptions QiniuKodo { get; set; } = new();
/// <summary>
/// 阿里云 OSS 配置。
/// </summary>
[Required]
public AliyunOssOptions AliyunOss { get; set; } = new();
/// <summary>
/// 存储安全策略配置。
/// </summary>
[Required]
public StorageSecurityOptions Security { get; set; } = new();
}

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Module.Storage.Options;
/// <summary>
/// 文件安全与防盗链相关配置。
/// </summary>
public sealed class StorageSecurityOptions
{
/// <summary>
/// 单个文件最大尺寸(字节),默认 10MB。
/// </summary>
[Range(1, long.MaxValue)]
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// 允许的图片后缀名白名单。
/// </summary>
[MinLength(1)]
public string[] AllowedImageExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif" };
/// <summary>
/// 允许的通用文件后缀名白名单。
/// </summary>
[MinLength(1)]
public string[] AllowedFileExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" };
/// <summary>
/// 默认签名有效期(分钟),用于生成带过期时间的访问链接。
/// </summary>
[Range(1, 24 * 60)]
public int DefaultUrlExpirationMinutes { get; set; } = 30;
/// <summary>
/// 是否启用来源校验(防盗链),为空则不校验。
/// </summary>
public bool EnableRefererValidation { get; set; } = true;
/// <summary>
/// 允许的 Referer/Origin 前缀列表,用于限制上传接口调用来源。
/// </summary>
public string[] AllowedReferers { get; set; } = Array.Empty<string>();
/// <summary>
/// 针对 CDN 防盗链的额外签名密钥(可选),用于生成二次校验签名。
/// </summary>
public string? AntiLeechTokenSecret { get; set; }
}

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Module.Storage.Options;
/// <summary>
/// 腾讯云 COS 访问配置。
/// </summary>
public sealed class TencentCosOptions
{
/// <summary>
/// SecretId。
/// </summary>
[Required]
public string SecretId { get; set; } = string.Empty;
/// <summary>
/// SecretKey。
/// </summary>
[Required]
public string SecretKey { get; set; } = string.Empty;
/// <summary>
/// 存储地域(如 ap-guangzhou
/// </summary>
[Required]
public string Region { get; set; } = string.Empty;
/// <summary>
/// 存储桶名称(含 AppId如 takeout-bucket-123456
/// </summary>
[Required]
public string Bucket { get; set; } = string.Empty;
/// <summary>
/// COS 自定义域名或 API Endpoint可选未配置则根据 Region 生成默认域名。
/// </summary>
public string? Endpoint { get; set; }
/// <summary>
/// CDN 域名(可选),用于生成加速访问地址。
/// </summary>
[Url]
public string? CdnBaseUrl { get; set; }
/// <summary>
/// 是否使用 HTTPS。
/// </summary>
public bool UseHttps { get; set; } = true;
/// <summary>
/// 是否强制使用 PathStyle 访问COS 默认可使用虚拟主机形式。
/// </summary>
public bool ForcePathStyle { get; set; }
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Module.Storage.Options;
namespace TakeoutSaaS.Module.Storage.Providers;
/// <summary>
/// 七牛云 KodoS3 兼容网关)存储提供商。
/// </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));
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Module.Storage.Abstractions;
using TakeoutSaaS.Module.Storage.Options;
namespace TakeoutSaaS.Module.Storage.Services;
/// <summary>
/// 存储提供商解析器,实现基于配置的提供商选择。
/// </summary>
public sealed class StorageProviderResolver(IOptionsMonitor<StorageOptions> optionsMonitor, IEnumerable<IObjectStorageProvider> providers)
: IStorageProviderResolver
{
private readonly IDictionary<StorageProviderKind, IObjectStorageProvider> _providerMap =
providers.ToDictionary(x => x.Kind, x => x);
/// <inheritdoc />
public IObjectStorageProvider Resolve(StorageProviderKind? provider = null)
{
var target = provider ?? optionsMonitor.CurrentValue.Provider;
if (_providerMap.TryGetValue(target, out var instance))
{
return instance;
}
throw new InvalidOperationException($"未注册存储提供商:{target}");
}
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Module.Storage;
/// <summary>
/// 存储提供商类型枚举,便于通过配置选择具体的对象存储实现。
/// </summary>
public enum StorageProviderKind
{
/// <summary>
/// 腾讯云 COS 对象存储。
/// </summary>
TencentCos = 1,
/// <summary>
/// 七牛云 Kodo 存储。
/// </summary>
QiniuKodo = 2,
/// <summary>
/// 阿里云 OSS 存储。
/// </summary>
AliyunOss = 3
}

View File

@@ -4,8 +4,16 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.408" />
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.13.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
</Project>