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