feat(dev): containerize local dependencies
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-04 13:27:25 +08:00
parent 33375a38d7
commit a517ffc6a7
15 changed files with 428 additions and 73 deletions

View File

@@ -4,6 +4,9 @@ using System.Text.RegularExpressions;
using AlibabaCloud.OSS.V2.Credentials;
using AlibabaCloud.OSS.V2.Models;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using Tiku.Application.Storage;
using Oss = AlibabaCloud.OSS.V2;
@@ -11,7 +14,8 @@ namespace Tiku.Infrastructure.Storage;
public sealed partial class AliyunOssObjectStorageService(
IOptions<ObjectStorageOptions> storageOptions,
IOptions<AliyunOssOptions> aliyunOptions)
IOptions<AliyunOssOptions> aliyunOptions,
IOptions<S3CompatibleOptions> s3Options)
: IObjectStorageService, IDisposable
{
private static readonly HashSet<string> SupportedProviders =
@@ -31,6 +35,7 @@ public sealed partial class AliyunOssObjectStorageService(
private readonly object clientLock = new();
private Oss.Client? client;
private IMinioClient? s3Client;
private bool disposed;
public void Dispose()
@@ -41,6 +46,8 @@ public sealed partial class AliyunOssObjectStorageService(
{
client?.Dispose();
client = null;
s3Client?.Dispose();
s3Client = null;
disposed = true;
}
}
@@ -138,14 +145,14 @@ public sealed partial class AliyunOssObjectStorageService(
public void AssertWritableLocation(StorageAssetLocation location)
{
var provider = NormalizeProvider(location.Provider);
if (provider == ObjectStorageProviders.AliyunOss &&
if (provider is ObjectStorageProviders.AliyunOss or ObjectStorageProviders.LocalDev &&
(string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey)))
throw new ObjectStorageException(
$"{provider} asset requires bucket and objectKey.",
"ASSET_OBJECT_LOCATION_REQUIRED");
}
public Task<ObjectStorageSignedUrl> SignUploadAsync(
public async Task<ObjectStorageSignedUrl> SignUploadAsync(
ObjectStorageUploadSignRequest request,
CancellationToken cancellationToken = default)
{
@@ -156,29 +163,39 @@ public sealed partial class AliyunOssObjectStorageService(
var mimeType = ValidateMimeType(request.MimeType);
ValidateFileSize(request.FileSizeBytes);
return provider == ObjectStorageProviders.LocalDev
? Task.FromResult(LocalSignedUrl(
if (provider == ObjectStorageProviders.LocalDev)
{
await EnsureS3BucketAsync(request.Bucket, cancellationToken);
var url = await GetS3Client().PresignedPutObjectAsync(new PresignedPutObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithExpiry(ExpirySeconds(request.ExpiresIn)));
return new ObjectStorageSignedUrl(
provider,
request.Bucket,
objectKey,
"PUT",
request.ExpiresIn,
"attachment",
new Uri(url, UriKind.Absolute),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["content-type"] = mimeType
}))
: Task.FromResult(SignAliyunOss(
},
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"s3-compatible-presigned-put");
}
return SignAliyunOss(
request.Bucket,
objectKey,
"PUT",
request.ExpiresIn,
mimeType,
null,
null));
null);
}
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
public async Task<ObjectStorageSignedUrl> SignDownloadAsync(
ObjectStorageDownloadSignRequest request,
CancellationToken cancellationToken = default)
{
@@ -186,7 +203,7 @@ public sealed partial class AliyunOssObjectStorageService(
var provider = NormalizeProvider(request.Provider);
if (!string.IsNullOrWhiteSpace(request.CdnUrl))
return Task.FromResult(new ObjectStorageSignedUrl(
return new ObjectStorageSignedUrl(
provider,
request.Bucket,
request.ObjectKey,
@@ -195,20 +212,36 @@ public sealed partial class AliyunOssObjectStorageService(
new Dictionary<string, string>(),
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"public-or-provider-managed"));
"public-or-provider-managed");
if (string.IsNullOrWhiteSpace(request.ObjectKey))
throw new ObjectStorageException("Asset requires objectKey or cdnUrl.", "ASSET_LOCATION_REQUIRED");
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
if (provider is ObjectStorageProviders.LocalDev or ObjectStorageProviders.QiniuKodo)
return Task.FromResult(LocalSignedUrl(
if (provider == ObjectStorageProviders.LocalDev)
{
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("S3-compatible asset requires bucket.",
"ASSET_OBJECT_LOCATION_REQUIRED");
var url = await GetS3Client().PresignedGetObjectAsync(new PresignedGetObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithExpiry(ExpirySeconds(request.ExpiresIn)));
return new ObjectStorageSignedUrl(
provider,
request.Bucket,
objectKey,
"GET",
new Uri(url, UriKind.Absolute),
new Dictionary<string, string>(),
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
request.Disposition));
"s3-compatible-presigned-get");
}
if (provider == ObjectStorageProviders.QiniuKodo)
return LocalSignedUrl(provider, request.Bucket, objectKey, "GET", request.ExpiresIn,
request.Disposition);
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");
@@ -216,14 +249,14 @@ public sealed partial class AliyunOssObjectStorageService(
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
return Task.FromResult(SignAliyunOss(
return SignAliyunOss(
request.Bucket,
objectKey,
"GET",
request.ExpiresIn,
null,
request.FileName,
request.Disposition));
request.Disposition);
}
public async Task<ObjectStorageMetadata> HeadObjectAsync(
@@ -241,18 +274,34 @@ public sealed partial class AliyunOssObjectStorageService(
var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant();
if (provider == ObjectStorageProviders.LocalDev)
return new ObjectStorageMetadata(
provider,
request.Bucket,
objectKey,
true,
fileSizeBytes,
mimeType,
checksumSha256,
checksumSha256,
DateTimeOffset.UtcNow.ToString("O"),
new Dictionary<string, string>(),
"local-dev-declared-metadata");
{
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("S3-compatible asset requires bucket.",
"ASSET_OBJECT_LOCATION_REQUIRED");
try
{
var result = await GetS3Client().StatObjectAsync(new StatObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey), cancellationToken);
return new ObjectStorageMetadata(
provider,
request.Bucket,
objectKey,
true,
result.Size,
result.ContentType,
checksumSha256,
result.ETag,
result.LastModified.ToString("O"),
new Dictionary<string, string>(),
"s3-compatible-stat-object");
}
catch (ObjectNotFoundException)
{
throw new ObjectStorageException("Uploaded object was not found in S3-compatible storage.",
"STORAGE_OBJECT_NOT_FOUND");
}
}
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException(
@@ -298,17 +347,47 @@ public sealed partial class AliyunOssObjectStorageService(
throw new ObjectStorageException("Writable asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
if (provider == ObjectStorageProviders.LocalDev)
return new ObjectStorageWriteResult(
provider,
request.Bucket,
objectKey,
BuildPublicUrl(request.Bucket, objectKey),
fileSizeBytes,
mimeType,
checksumSha256 ?? await ComputeSha256Async(request.Content, cancellationToken),
null,
new Dictionary<string, string>(),
"local-dev-write-placeholder");
{
await EnsureS3BucketAsync(request.Bucket, cancellationToken);
MemoryStream? bufferedContent = null;
var uploadContent = request.Content;
if (checksumSha256 is null && !request.Content.CanSeek)
{
bufferedContent = new MemoryStream();
await request.Content.CopyToAsync(bufferedContent, cancellationToken);
bufferedContent.Position = 0;
uploadContent = bufferedContent;
}
var resolvedChecksum = checksumSha256 ?? await ComputeSha256Async(uploadContent, cancellationToken);
var objectSize = fileSizeBytes ?? (uploadContent.CanSeek
? uploadContent.Length - uploadContent.Position
: -1);
try
{
await GetS3Client().PutObjectAsync(new PutObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithStreamData(uploadContent)
.WithObjectSize(objectSize)
.WithContentType(mimeType), cancellationToken);
return new ObjectStorageWriteResult(
provider,
request.Bucket,
objectKey,
BuildPublicUrl(request.Bucket, objectKey),
fileSizeBytes ?? objectSize,
mimeType,
resolvedChecksum,
null,
new Dictionary<string, string>(),
"s3-compatible-put-object");
}
finally
{
if (bufferedContent is not null) await bufferedContent.DisposeAsync();
}
}
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException(
@@ -353,6 +432,30 @@ public sealed partial class AliyunOssObjectStorageService(
{
var provider = NormalizeProvider(request.Provider);
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
if (provider == ObjectStorageProviders.LocalDev)
{
if (string.IsNullOrWhiteSpace(request.Bucket))
throw new ObjectStorageException("S3-compatible asset requires bucket.",
"ASSET_OBJECT_LOCATION_REQUIRED");
var content = new MemoryStream();
try
{
await GetS3Client().GetObjectAsync(new GetObjectArgs()
.WithBucket(request.Bucket)
.WithObject(objectKey)
.WithCallbackStream(async (stream, token) =>
await stream.CopyToAsync(content, token)), cancellationToken);
content.Position = 0;
return content;
}
catch (ObjectNotFoundException)
{
await content.DisposeAsync();
throw new ObjectStorageException("Object was not found in S3-compatible storage.",
"STORAGE_OBJECT_NOT_FOUND");
}
}
if (provider != ObjectStorageProviders.AliyunOss)
throw new ObjectStorageException(
$"{provider} does not support managed server-side reads.",
@@ -457,6 +560,43 @@ public sealed partial class AliyunOssObjectStorageService(
}
}
private IMinioClient GetS3Client()
{
ObjectDisposedException.ThrowIf(disposed, this);
if (s3Client is not null) return s3Client;
lock (clientLock)
{
if (s3Client is not null) return s3Client;
var options = s3Options.Value;
if (!options.IsConfigured)
throw new ObjectStorageNotConfiguredException(
"S3-compatible storage is not configured: S3_ENDPOINT, S3_ACCESS_KEY and S3_SECRET_KEY are required.");
var builder = new MinioClient()
.WithEndpoint(options.Endpoint!.Trim())
.WithCredentials(options.AccessKey!.Trim(), options.SecretKey!.Trim())
.WithSSL(options.Secure);
if (!string.IsNullOrWhiteSpace(options.Region)) builder = builder.WithRegion(options.Region.Trim());
s3Client = builder.Build();
return s3Client;
}
}
private async Task EnsureS3BucketAsync(string bucket, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
var client = GetS3Client();
var exists = await client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), cancellationToken);
if (!exists)
await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), cancellationToken);
}
private static int ExpirySeconds(TimeSpan expiresIn)
{
return Math.Clamp((int)Math.Ceiling(expiresIn.TotalSeconds), 1, 7 * 24 * 60 * 60);
}
private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
{
if (!currentOptions.IsConfigured)
@@ -625,4 +765,4 @@ public sealed partial class AliyunOssObjectStorageService(
[GeneratedRegex("/{2,}")]
private static partial Regex ConsecutiveSlashRegex();
}
}

View File

@@ -0,0 +1,17 @@
namespace Tiku.Infrastructure.Storage;
public sealed class S3CompatibleOptions
{
public const string SectionName = "Storage:S3Compatible";
public string? Endpoint { get; set; }
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public string? Region { get; set; }
public bool Secure { get; set; }
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(Endpoint) &&
!string.IsNullOrWhiteSpace(AccessKey) &&
!string.IsNullOrWhiteSpace(SecretKey);
}

View File

@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis"/>
<PackageReference Include="StackExchange.Redis"/>
<PackageReference Include="Microsoft.SemanticKernel"/>
<PackageReference Include="Minio"/>
<PackageReference Include="Npgsql"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL"/>
<PackageReference Include="Senparc.Weixin"/>