feat: add aliyun oss storage foundation
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
|
||||
23
README.md
23
README.md
@@ -98,6 +98,17 @@ Tiku.IntegrationTests # EF 模型/持久化约束测试
|
||||
- 当前先在小范围权限判断里落模板,后续题库筛选、权限集合、菜单/内容树投影等热路径再逐步使用。
|
||||
- 不是为了炫技替换所有 LINQ;只在明确高频、低收益分配明显的路径使用。
|
||||
|
||||
### 对象存储
|
||||
|
||||
- 旧 Nest 后端资源层使用 Node `ali-oss`,不是 Supabase Storage 专用模型。
|
||||
- 新后端使用 `AlibabaCloud.OSS.V2` 作为阿里云 OSS SDK,对齐旧版的 provider / bucket / objectKey / signed URL / HEAD metadata 语义。
|
||||
- Application 层只依赖 `IObjectStorageService`,不直接依赖阿里云 SDK;SDK 细节收敛在 Infrastructure。
|
||||
- 默认对象 key 要带租户前缀,例如 `{tenantId}/...`,避免多租户资源混放后靠人工约定隔离。
|
||||
- 上传签名前会校验 MIME allowlist、文件大小、provider 是否支持托管上传。
|
||||
- 下载时如果资源已有可信 `cdnUrl`,直接返回 provider-managed URL;否则由后端在通过业务授权后签发临时 URL。
|
||||
- 上传确认使用 OSS HEAD metadata,后续可用于校验大小、MIME、ETag、SHA256 metadata 和安全扫描状态。
|
||||
- 不再把 Supabase Storage 作为默认后端存储;如果历史数据里存在 Supabase provider,只作为迁移兼容对象处理,不作为新架构依赖。
|
||||
|
||||
## 数据库策略
|
||||
|
||||
当前数据库以 PostgreSQL 为核心能力,而不是把 PostgreSQL 当成普通 KV 存储:
|
||||
@@ -213,6 +224,18 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs
|
||||
- 后台 Worker 使用最小权限的应用服务,不直接绕过业务规则写库。
|
||||
- 对账、导入、批处理任务要求幂等键和可重跑设计。
|
||||
- 生产环境连接串、密钥、对象存储凭据不进入仓库。
|
||||
- OSS 凭据通过配置/环境变量/secret 注入,兼容旧版环境变量:
|
||||
- `STORAGE_DEFAULT_PROVIDER`
|
||||
- `STORAGE_DEFAULT_BUCKET`
|
||||
- `STORAGE_PUBLIC_BASE_URL`
|
||||
- `STORAGE_REQUIRE_TENANT_PREFIX`
|
||||
- `STORAGE_MAX_UPLOAD_BYTES`
|
||||
- `ALIYUN_OSS_REGION`
|
||||
- `ALIYUN_OSS_ENDPOINT`
|
||||
- `ALIYUN_OSS_ACCESS_KEY_ID`
|
||||
- `ALIYUN_OSS_ACCESS_KEY_SECRET`
|
||||
- `ALIYUN_OSS_STS_TOKEN`
|
||||
- `ALIYUN_OSS_INTERNAL`
|
||||
|
||||
## 常用命令
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ using Tiku.Application;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
@@ -138,6 +139,39 @@ try
|
||||
"Host=localhost;Database=tiku;Username=postgres";
|
||||
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
builder.Services.Configure<ObjectStorageOptions>(
|
||||
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
builder.Services.Configure<AliyunOssOptions>(
|
||||
builder.Configuration.GetSection(AliyunOssOptions.SectionName));
|
||||
builder.Services.PostConfigure<ObjectStorageOptions>(options =>
|
||||
{
|
||||
options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
|
||||
options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
|
||||
options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
|
||||
options.AllowedMimePrefixes = SplitLegacyList(
|
||||
builder.Configuration["STORAGE_ALLOWED_MIME_PREFIXES"],
|
||||
options.AllowedMimePrefixes);
|
||||
options.AllowedMimeTypes = SplitLegacyList(
|
||||
builder.Configuration["STORAGE_ALLOWED_MIME_TYPES"],
|
||||
options.AllowedMimeTypes);
|
||||
options.RequireTenantPrefix = bool.TryParse(builder.Configuration["STORAGE_REQUIRE_TENANT_PREFIX"], out var requireTenantPrefix)
|
||||
? requireTenantPrefix
|
||||
: options.RequireTenantPrefix;
|
||||
options.MaxUploadBytes = long.TryParse(builder.Configuration["STORAGE_MAX_UPLOAD_BYTES"], out var maxUploadBytes)
|
||||
? maxUploadBytes
|
||||
: options.MaxUploadBytes;
|
||||
});
|
||||
builder.Services.PostConfigure<AliyunOssOptions>(options =>
|
||||
{
|
||||
options.Region = builder.Configuration["ALIYUN_OSS_REGION"] ?? options.Region;
|
||||
options.Endpoint = builder.Configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint;
|
||||
options.AccessKeyId = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId;
|
||||
options.AccessKeySecret = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret;
|
||||
options.SecurityToken = builder.Configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken;
|
||||
options.UseInternalEndpoint = bool.TryParse(builder.Configuration["ALIYUN_OSS_INTERNAL"], out var useInternalEndpoint)
|
||||
? useInternalEndpoint
|
||||
: options.UseInternalEndpoint;
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<JwtOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Security:Jwt"))
|
||||
@@ -250,4 +284,9 @@ finally
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
static string[] SplitLegacyList(string? value, string[] fallback) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -23,5 +23,12 @@
|
||||
"PermitLimit": 1200,
|
||||
"WindowSeconds": 60,
|
||||
"QueueLimit": 0
|
||||
},
|
||||
"Storage": {
|
||||
"AliyunOss": {
|
||||
"Region": "cn-hangzhou",
|
||||
"Endpoint": "",
|
||||
"UseInternalEndpoint": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,37 @@
|
||||
"WindowSeconds": 60,
|
||||
"QueueLimit": 0
|
||||
},
|
||||
"Storage": {
|
||||
"DefaultProvider": "aliyun_oss",
|
||||
"DefaultBucket": "tenant-assets",
|
||||
"PublicBaseUrl": "",
|
||||
"MaxUploadBytes": 524288000,
|
||||
"AllowedMimePrefixes": [
|
||||
"image/",
|
||||
"video/",
|
||||
"audio/"
|
||||
],
|
||||
"AllowedMimeTypes": [
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"text/csv",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
],
|
||||
"RequireTenantPrefix": true,
|
||||
"AliyunOss": {
|
||||
"Region": "",
|
||||
"Endpoint": "",
|
||||
"AccessKeyId": "",
|
||||
"AccessKeySecret": "",
|
||||
"SecurityToken": "",
|
||||
"UseInternalEndpoint": false,
|
||||
"UsePathStyle": false,
|
||||
"UseCName": false,
|
||||
"PresignDefaultMinutes": 15
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Jwt": {
|
||||
"Issuer": "tiku-backend",
|
||||
|
||||
25
Tiku.Application/Storage/IObjectStorageService.cs
Normal file
25
Tiku.Application/Storage/IObjectStorageService.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace Tiku.Application.Storage;
|
||||
|
||||
public interface IObjectStorageService
|
||||
{
|
||||
string ConfiguredDefaultProvider();
|
||||
string ConfiguredDefaultBucket();
|
||||
string NormalizeProvider(string? value, string? fallback = null);
|
||||
string ValidateObjectKey(Guid tenantId, string objectKey);
|
||||
string ValidateMimeType(string mimeType);
|
||||
long? ValidateFileSize(long? fileSizeBytes);
|
||||
void AssertUploadProvider(string provider);
|
||||
void AssertWritableLocation(StorageAssetLocation location);
|
||||
|
||||
Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||
ObjectStorageUploadSignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||
ObjectStorageDownloadSignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
79
Tiku.Application/Storage/ObjectStorageContracts.cs
Normal file
79
Tiku.Application/Storage/ObjectStorageContracts.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
namespace Tiku.Application.Storage;
|
||||
|
||||
public static class ObjectStorageProviders
|
||||
{
|
||||
public const string ExternalUrl = "external_url";
|
||||
public const string SupabaseStorage = "supabase_storage";
|
||||
public const string AliyunOss = "aliyun_oss";
|
||||
public const string TencentCos = "tencent_cos";
|
||||
public const string QiniuKodo = "qiniu_kodo";
|
||||
public const string LocalDev = "local_dev";
|
||||
}
|
||||
|
||||
public sealed record StorageAssetLocation(
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
string? ObjectKey,
|
||||
string? CdnUrl = null);
|
||||
|
||||
public sealed record ObjectStorageUploadSignRequest(
|
||||
Guid TenantId,
|
||||
string Provider,
|
||||
string Bucket,
|
||||
string ObjectKey,
|
||||
string FileName,
|
||||
string MimeType,
|
||||
long? FileSizeBytes,
|
||||
TimeSpan ExpiresIn,
|
||||
bool Upsert = false);
|
||||
|
||||
public sealed record ObjectStorageDownloadSignRequest(
|
||||
Guid TenantId,
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
string? ObjectKey,
|
||||
TimeSpan ExpiresIn,
|
||||
string? CdnUrl = null,
|
||||
string? FileName = null,
|
||||
string Disposition = "attachment");
|
||||
|
||||
public sealed record ObjectStorageHeadRequest(
|
||||
Guid TenantId,
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
string? ObjectKey,
|
||||
string? DeclaredMimeType = null,
|
||||
long? DeclaredFileSizeBytes = null,
|
||||
string? DeclaredChecksumSha256 = null);
|
||||
|
||||
public sealed record ObjectStorageSignedUrl(
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
string? ObjectKey,
|
||||
string Method,
|
||||
Uri Url,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
DateTimeOffset ExpiresAt,
|
||||
TimeSpan ExpiresIn,
|
||||
string SignatureMode);
|
||||
|
||||
public sealed record ObjectStorageMetadata(
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
string? ObjectKey,
|
||||
bool Exists,
|
||||
long? SizeBytes,
|
||||
string? MimeType,
|
||||
string? ChecksumSha256,
|
||||
string? ETag,
|
||||
string? LastModified,
|
||||
IReadOnlyDictionary<string, string> RawHeaders,
|
||||
string VerificationSource);
|
||||
|
||||
public class ObjectStorageException(string message, string code) : InvalidOperationException(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
|
||||
public sealed class ObjectStorageNotConfiguredException(string message)
|
||||
: ObjectStorageException(message, "STORAGE_PROVIDER_NOT_CONFIGURED");
|
||||
@@ -5,12 +5,14 @@ using Tiku.Application.Auth;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.StudyContent;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
@@ -40,6 +42,15 @@ public static class DependencyInjection
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate(
|
||||
AliyunOssOptions.BeValid,
|
||||
"Aliyun OSS presign default expiration must be positive.");
|
||||
services.AddOptions<ObjectStorageOptions>()
|
||||
.Validate(
|
||||
ObjectStorageOptions.BeValid,
|
||||
"Storage options must include a default provider, default bucket and positive max upload bytes.");
|
||||
services.AddSingleton<IObjectStorageService, AliyunOssObjectStorageService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
564
Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
Normal file
564
Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
Normal file
@@ -0,0 +1,564 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AlibabaCloud.OSS.V2.Credentials;
|
||||
using AlibabaCloud.OSS.V2.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Storage;
|
||||
using Oss = AlibabaCloud.OSS.V2;
|
||||
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed partial class AliyunOssObjectStorageService(
|
||||
IOptions<ObjectStorageOptions> storageOptions,
|
||||
IOptions<AliyunOssOptions> aliyunOptions)
|
||||
: IObjectStorageService, IDisposable
|
||||
{
|
||||
private static readonly HashSet<string> SupportedProviders =
|
||||
[
|
||||
ObjectStorageProviders.ExternalUrl,
|
||||
ObjectStorageProviders.SupabaseStorage,
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
ObjectStorageProviders.TencentCos,
|
||||
ObjectStorageProviders.QiniuKodo,
|
||||
ObjectStorageProviders.LocalDev
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> UploadableProviders =
|
||||
[
|
||||
ObjectStorageProviders.LocalDev,
|
||||
ObjectStorageProviders.AliyunOss
|
||||
];
|
||||
|
||||
private readonly object clientLock = new();
|
||||
private Oss.Client? client;
|
||||
private bool disposed;
|
||||
|
||||
public string ConfiguredDefaultProvider() =>
|
||||
NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev);
|
||||
|
||||
public string ConfiguredDefaultBucket() =>
|
||||
string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket)
|
||||
? "tenant-assets"
|
||||
: storageOptions.Value.DefaultBucket.Trim();
|
||||
|
||||
public string NormalizeProvider(string? value, string? fallback = null)
|
||||
{
|
||||
var provider = (string.IsNullOrWhiteSpace(value) ? fallback : value)?.Trim() ?? ConfiguredDefaultProvider();
|
||||
if (!SupportedProviders.Contains(provider))
|
||||
{
|
||||
throw new ObjectStorageException($"Unsupported storage provider: {provider}", "UNSUPPORTED_STORAGE_PROVIDER");
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
public string ValidateObjectKey(Guid tenantId, string objectKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(objectKey);
|
||||
|
||||
var clean = CanonicalObjectKey(objectKey);
|
||||
if (string.IsNullOrWhiteSpace(clean) ||
|
||||
clean.Contains("..", StringComparison.Ordinal) ||
|
||||
clean.Contains('\\', StringComparison.Ordinal) ||
|
||||
clean.Contains("%2f", StringComparison.OrdinalIgnoreCase) ||
|
||||
!SafeObjectKeyRegex().IsMatch(clean))
|
||||
{
|
||||
throw new ObjectStorageException("Invalid objectKey.", "INVALID_OBJECT_KEY");
|
||||
}
|
||||
|
||||
if (storageOptions.Value.RequireTenantPrefix && !clean.StartsWith($"{tenantId:N}/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"objectKey must be scoped by tenantId prefix.",
|
||||
"OBJECT_KEY_TENANT_PREFIX_REQUIRED");
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
public string ValidateMimeType(string mimeType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mimeType);
|
||||
|
||||
var normalized = mimeType.Trim().ToLowerInvariant();
|
||||
if (normalized.Length > 160 ||
|
||||
normalized.Contains('\r', StringComparison.Ordinal) ||
|
||||
normalized.Contains('\n', StringComparison.Ordinal))
|
||||
{
|
||||
throw new ObjectStorageException("Invalid mimeType.", "INVALID_MIME_TYPE");
|
||||
}
|
||||
|
||||
var exact = storageOptions.Value.AllowedMimeTypes
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Where(item => item.Length > 0)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var prefixes = storageOptions.Value.AllowedMimePrefixes
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Where(item => item.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
if (!exact.Contains(normalized) &&
|
||||
!prefixes.Any(prefix => normalized.StartsWith(prefix, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new ObjectStorageException($"mimeType is not allowed: {normalized}", "MIME_TYPE_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public long? ValidateFileSize(long? fileSizeBytes)
|
||||
{
|
||||
if (fileSizeBytes is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileSizeBytes < 0)
|
||||
{
|
||||
throw new ObjectStorageException("fileSizeBytes must be non-negative.", "INVALID_FILE_SIZE");
|
||||
}
|
||||
|
||||
if (fileSizeBytes > storageOptions.Value.MaxUploadBytes)
|
||||
{
|
||||
throw new ObjectStorageException("file exceeds configured storage max upload bytes.", "FILE_TOO_LARGE");
|
||||
}
|
||||
|
||||
return fileSizeBytes;
|
||||
}
|
||||
|
||||
public void AssertUploadProvider(string provider)
|
||||
{
|
||||
var normalized = NormalizeProvider(provider);
|
||||
if (!UploadableProviders.Contains(normalized))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
$"{normalized} does not support managed upload signing.",
|
||||
"UPLOAD_PROVIDER_NOT_SUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
public void AssertWritableLocation(StorageAssetLocation location)
|
||||
{
|
||||
var provider = NormalizeProvider(location.Provider);
|
||||
if (provider == ObjectStorageProviders.AliyunOss &&
|
||||
(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(
|
||||
ObjectStorageUploadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
AssertUploadProvider(provider);
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
var mimeType = ValidateMimeType(request.MimeType);
|
||||
ValidateFileSize(request.FileSizeBytes);
|
||||
|
||||
return provider == ObjectStorageProviders.LocalDev
|
||||
? Task.FromResult(LocalSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"PUT",
|
||||
request.ExpiresIn,
|
||||
"attachment",
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["content-type"] = mimeType
|
||||
}))
|
||||
: Task.FromResult(SignAliyunOss(
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"PUT",
|
||||
request.ExpiresIn,
|
||||
mimeType: mimeType,
|
||||
fileName: null,
|
||||
disposition: null));
|
||||
}
|
||||
|
||||
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||
ObjectStorageDownloadSignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CdnUrl))
|
||||
{
|
||||
return Task.FromResult(new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
request.Bucket,
|
||||
request.ObjectKey,
|
||||
"GET",
|
||||
new Uri(request.CdnUrl, UriKind.Absolute),
|
||||
new Dictionary<string, string>(),
|
||||
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
||||
request.ExpiresIn,
|
||||
"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(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"GET",
|
||||
request.ExpiresIn,
|
||||
request.Disposition));
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
{
|
||||
throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
{
|
||||
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
return Task.FromResult(SignAliyunOss(
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
"GET",
|
||||
request.ExpiresIn,
|
||||
mimeType: null,
|
||||
request.FileName,
|
||||
request.Disposition));
|
||||
}
|
||||
|
||||
public async Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
if (string.IsNullOrWhiteSpace(request.ObjectKey))
|
||||
{
|
||||
throw new ObjectStorageException("Asset requires objectKey.", "ASSET_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
var fileSizeBytes = ValidateFileSize(request.DeclaredFileSizeBytes);
|
||||
var mimeType = request.DeclaredMimeType is null ? null : ValidateMimeType(request.DeclaredMimeType);
|
||||
var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant();
|
||||
|
||||
if (provider == ObjectStorageProviders.LocalDev)
|
||||
{
|
||||
return new ObjectStorageMetadata(
|
||||
provider,
|
||||
request.Bucket,
|
||||
objectKey,
|
||||
Exists: true,
|
||||
fileSizeBytes,
|
||||
mimeType,
|
||||
checksumSha256,
|
||||
checksumSha256,
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
new Dictionary<string, string>(),
|
||||
"local-dev-declared-metadata");
|
||||
}
|
||||
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
$"{provider} does not support upload confirmation.",
|
||||
"UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
{
|
||||
throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await GetClient().HeadObjectAsync(new HeadObjectRequest
|
||||
{
|
||||
Bucket = request.Bucket,
|
||||
Key = objectKey
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
return MetadataFromHeadResult(request.Bucket, objectKey, result);
|
||||
}
|
||||
catch (Exception exception) when (TryGetStatusCode(exception) == 404)
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"Uploaded object was not found in Aliyun OSS.",
|
||||
"STORAGE_OBJECT_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (clientLock)
|
||||
{
|
||||
client?.Dispose();
|
||||
client = null;
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectStorageSignedUrl SignAliyunOss(
|
||||
string bucket,
|
||||
string objectKey,
|
||||
string method,
|
||||
TimeSpan expiresIn,
|
||||
string? mimeType,
|
||||
string? fileName,
|
||||
string? disposition)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bucket);
|
||||
var expiresAt = ResolveExpiration(expiresIn);
|
||||
|
||||
PresignResult result;
|
||||
if (method == "PUT")
|
||||
{
|
||||
result = GetClient().Presign(new PutObjectRequest
|
||||
{
|
||||
Bucket = bucket,
|
||||
Key = objectKey,
|
||||
ContentType = mimeType
|
||||
}, expiresAt.UtcDateTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = GetClient().Presign(new GetObjectRequest
|
||||
{
|
||||
Bucket = bucket,
|
||||
Key = objectKey,
|
||||
ResponseContentDisposition = BuildContentDisposition(fileName, disposition)
|
||||
}, expiresAt.UtcDateTime);
|
||||
}
|
||||
|
||||
return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn, "aliyun-oss-signature-url-v4");
|
||||
}
|
||||
|
||||
private ObjectStorageSignedUrl LocalSignedUrl(
|
||||
string provider,
|
||||
string? bucket,
|
||||
string? objectKey,
|
||||
string method,
|
||||
TimeSpan expiresIn,
|
||||
string? disposition,
|
||||
IReadOnlyDictionary<string, string>? headers = null)
|
||||
{
|
||||
var expiresAt = ResolveExpiration(expiresIn);
|
||||
var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/');
|
||||
var path = $"{Uri.EscapeDataString(bucket ?? "default")}/{Uri.EscapeDataString(objectKey ?? "missing")}";
|
||||
var url = string.IsNullOrWhiteSpace(baseUrl)
|
||||
? $"https://local-storage.invalid/{provider}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}"
|
||||
: $"{baseUrl}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}";
|
||||
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
bucket,
|
||||
objectKey,
|
||||
method,
|
||||
new Uri(url),
|
||||
headers ?? new Dictionary<string, string>(),
|
||||
expiresAt,
|
||||
expiresIn,
|
||||
"local-placeholder");
|
||||
}
|
||||
|
||||
private Oss.Client GetClient()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (client is not null)
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
lock (clientLock)
|
||||
{
|
||||
client ??= CreateClient(aliyunOptions.Value);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
private static Oss.Client CreateClient(AliyunOssOptions currentOptions)
|
||||
{
|
||||
if (!currentOptions.IsConfigured)
|
||||
{
|
||||
throw new ObjectStorageNotConfiguredException(
|
||||
"aliyun_oss is not configured: ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET and ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT are required.");
|
||||
}
|
||||
|
||||
var configuration = Oss.Configuration.LoadDefault();
|
||||
configuration.Region = currentOptions.Region?.Trim();
|
||||
configuration.Endpoint = NullIfWhiteSpace(currentOptions.Endpoint);
|
||||
configuration.UseInternalEndpoint = currentOptions.UseInternalEndpoint;
|
||||
configuration.UsePathStyle = currentOptions.UsePathStyle;
|
||||
configuration.UseCName = currentOptions.UseCName;
|
||||
configuration.DisableSsl = false;
|
||||
configuration.CredentialsProvider = string.IsNullOrWhiteSpace(currentOptions.SecurityToken)
|
||||
? new StaticCredentialsProvider(
|
||||
currentOptions.AccessKeyId!.Trim(),
|
||||
currentOptions.AccessKeySecret!.Trim())
|
||||
: new StaticCredentialsProvider(
|
||||
currentOptions.AccessKeyId!.Trim(),
|
||||
currentOptions.AccessKeySecret!.Trim(),
|
||||
currentOptions.SecurityToken!.Trim());
|
||||
|
||||
return new Oss.Client(configuration);
|
||||
}
|
||||
|
||||
private static ObjectStorageSignedUrl ToSignedUrl(
|
||||
PresignResult result,
|
||||
string provider,
|
||||
string bucket,
|
||||
string objectKey,
|
||||
string method,
|
||||
TimeSpan expiresIn,
|
||||
string signatureMode)
|
||||
{
|
||||
if (!Uri.TryCreate(result.Url, UriKind.Absolute, out var url))
|
||||
{
|
||||
throw new InvalidOperationException("Aliyun OSS SDK returned an invalid presigned URL.");
|
||||
}
|
||||
|
||||
return new ObjectStorageSignedUrl(
|
||||
provider,
|
||||
bucket,
|
||||
objectKey,
|
||||
string.IsNullOrWhiteSpace(result.Method) ? method : result.Method,
|
||||
url,
|
||||
result.SignedHeaders is null
|
||||
? new Dictionary<string, string>()
|
||||
: new Dictionary<string, string>(result.SignedHeaders, StringComparer.OrdinalIgnoreCase),
|
||||
result.Expiration.HasValue
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(result.Expiration.Value, DateTimeKind.Utc))
|
||||
: DateTimeOffset.UtcNow.Add(expiresIn),
|
||||
expiresIn,
|
||||
signatureMode);
|
||||
}
|
||||
|
||||
private static ObjectStorageMetadata MetadataFromHeadResult(
|
||||
string bucket,
|
||||
string objectKey,
|
||||
HeadObjectResult result)
|
||||
{
|
||||
var headers = NormalizeHeaders(result.Headers);
|
||||
foreach (var metadata in result.Metadata ?? new Dictionary<string, string>())
|
||||
{
|
||||
headers[$"x-oss-meta-{metadata.Key}".ToLowerInvariant()] = metadata.Value;
|
||||
}
|
||||
|
||||
return new ObjectStorageMetadata(
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
bucket,
|
||||
objectKey,
|
||||
Exists: true,
|
||||
result.ContentLength,
|
||||
NullIfWhiteSpace(result.ContentType),
|
||||
FirstHeader(headers, "x-oss-meta-sha256", "x-oss-meta-checksum-sha256")?.Trim().ToLowerInvariant(),
|
||||
CleanEtag(result.ETag ?? FirstHeader(headers, "etag")),
|
||||
NullIfWhiteSpace(result.LastModified) ?? FirstHeader(headers, "last-modified"),
|
||||
headers,
|
||||
"aliyun-oss-head-object");
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> NormalizeHeaders(IDictionary<string, string>? headers)
|
||||
{
|
||||
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (headers is null)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in headers)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && value is not null)
|
||||
{
|
||||
normalized[key.ToLowerInvariant()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static int? TryGetStatusCode(Exception exception)
|
||||
{
|
||||
var type = exception.GetType();
|
||||
var property = type.GetProperty("StatusCode", BindingFlags.Public | BindingFlags.Instance) ??
|
||||
type.GetProperty("Status", BindingFlags.Public | BindingFlags.Instance);
|
||||
return property?.GetValue(exception) switch
|
||||
{
|
||||
int statusCode => statusCode,
|
||||
long statusCode => checked((int)statusCode),
|
||||
string value when int.TryParse(value, out var statusCode) => statusCode,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset ResolveExpiration(TimeSpan expiresIn)
|
||||
{
|
||||
if (expiresIn <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn, "Signed URL expiration must be positive.");
|
||||
}
|
||||
|
||||
return DateTimeOffset.UtcNow.Add(expiresIn);
|
||||
}
|
||||
|
||||
private static string CanonicalObjectKey(string objectKey) =>
|
||||
ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/");
|
||||
|
||||
private static string? BuildContentDisposition(string? fileName, string? disposition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedDisposition = string.Equals(disposition, "inline", StringComparison.OrdinalIgnoreCase)
|
||||
? "inline"
|
||||
: "attachment";
|
||||
return $"{normalizedDisposition}; filename=\"{Uri.EscapeDataString(fileName.Trim())}\"";
|
||||
}
|
||||
|
||||
private static string? FirstHeader(IReadOnlyDictionary<string, string> headers, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (headers.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? CleanEtag(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"');
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
[GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")]
|
||||
private static partial Regex SafeObjectKeyRegex();
|
||||
|
||||
[GeneratedRegex("/{2,}")]
|
||||
private static partial Regex ConsecutiveSlashRegex();
|
||||
}
|
||||
26
Tiku.Infrastructure/Storage/AliyunOssOptions.cs
Normal file
26
Tiku.Infrastructure/Storage/AliyunOssOptions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed class AliyunOssOptions
|
||||
{
|
||||
public const string SectionName = "Storage:AliyunOss";
|
||||
|
||||
public string? Region { get; set; }
|
||||
public string? Endpoint { get; set; }
|
||||
public string? AccessKeyId { get; set; }
|
||||
public string? AccessKeySecret { get; set; }
|
||||
public string? SecurityToken { get; set; }
|
||||
public bool UseInternalEndpoint { get; set; }
|
||||
public bool UsePathStyle { get; set; }
|
||||
public bool UseCName { get; set; }
|
||||
public int PresignDefaultMinutes { get; set; } = 15;
|
||||
|
||||
public bool IsConfigured =>
|
||||
(!string.IsNullOrWhiteSpace(Region) || !string.IsNullOrWhiteSpace(Endpoint)) &&
|
||||
!string.IsNullOrWhiteSpace(AccessKeyId) &&
|
||||
!string.IsNullOrWhiteSpace(AccessKeySecret);
|
||||
|
||||
public static bool BeValid(AliyunOssOptions options)
|
||||
{
|
||||
return options.PresignDefaultMinutes > 0;
|
||||
}
|
||||
}
|
||||
28
Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
Normal file
28
Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Tiku.Infrastructure.Storage;
|
||||
|
||||
public sealed class ObjectStorageOptions
|
||||
{
|
||||
public const string SectionName = "Storage";
|
||||
|
||||
public string DefaultProvider { get; set; } = "local_dev";
|
||||
public string DefaultBucket { get; set; } = "tenant-assets";
|
||||
public string? PublicBaseUrl { get; set; }
|
||||
public long MaxUploadBytes { get; set; } = 1024L * 1024 * 500;
|
||||
public string[] AllowedMimePrefixes { get; set; } = ["image/", "video/", "audio/"];
|
||||
public string[] AllowedMimeTypes { get; set; } =
|
||||
[
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"text/csv",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
];
|
||||
|
||||
public bool RequireTenantPrefix { get; set; } = true;
|
||||
|
||||
public static bool BeValid(ObjectStorageOptions options) =>
|
||||
options.MaxUploadBytes > 0 &&
|
||||
!string.IsNullOrWhiteSpace(options.DefaultProvider) &&
|
||||
!string.IsNullOrWhiteSpace(options.DefaultBucket);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AlibabaCloud.OSS.V2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
|
||||
134
Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs
Normal file
134
Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
|
||||
namespace Tiku.UnitTests.Storage;
|
||||
|
||||
public sealed class AliyunOssObjectStorageServiceTests
|
||||
{
|
||||
private static readonly Guid TenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
[Fact]
|
||||
public void Validate_object_key_requires_tenant_prefix_like_original_storage_service()
|
||||
{
|
||||
using var service = CreateConfiguredService();
|
||||
|
||||
var exception = Assert.Throws<ObjectStorageException>(() =>
|
||||
service.ValidateObjectKey(TenantId, "other-tenant/file.pdf"));
|
||||
|
||||
Assert.Equal("OBJECT_KEY_TENANT_PREFIX_REQUIRED", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_mime_type_and_size_use_configured_allow_list()
|
||||
{
|
||||
using var service = CreateConfiguredService(new ObjectStorageOptions
|
||||
{
|
||||
MaxUploadBytes = 100,
|
||||
AllowedMimePrefixes = ["image/"],
|
||||
AllowedMimeTypes = ["application/pdf"]
|
||||
});
|
||||
|
||||
Assert.Equal("image/png", service.ValidateMimeType(" Image/PNG "));
|
||||
Assert.Equal(99, service.ValidateFileSize(99));
|
||||
Assert.Equal("MIME_TYPE_NOT_ALLOWED", Assert.Throws<ObjectStorageException>(() => service.ValidateMimeType("text/html")).Code);
|
||||
Assert.Equal("FILE_TOO_LARGE", Assert.Throws<ObjectStorageException>(() => service.ValidateFileSize(101)).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_download_requires_complete_aliyun_oss_options()
|
||||
{
|
||||
using var service = CreateUnconfiguredService();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<ObjectStorageNotConfiguredException>(() =>
|
||||
service.SignDownloadAsync(new ObjectStorageDownloadSignRequest(
|
||||
TenantId,
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
"bucket",
|
||||
$"{TenantId:N}/path/file.pdf",
|
||||
TimeSpan.FromMinutes(15))));
|
||||
|
||||
Assert.Contains("ALIYUN_OSS_ACCESS_KEY_ID", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_download_generates_aliyun_oss_presigned_get_url()
|
||||
{
|
||||
using var service = CreateConfiguredService();
|
||||
|
||||
var signedUrl = await service.SignDownloadAsync(new ObjectStorageDownloadSignRequest(
|
||||
TenantId,
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
"tiku-assets",
|
||||
$"{TenantId:N}/handbook.pdf",
|
||||
TimeSpan.FromMinutes(15),
|
||||
FileName: "handbook.pdf",
|
||||
Disposition: "attachment"));
|
||||
|
||||
Assert.Equal(ObjectStorageProviders.AliyunOss, signedUrl.Provider);
|
||||
Assert.Equal("GET", signedUrl.Method);
|
||||
Assert.Equal("aliyun-oss-signature-url-v4", signedUrl.SignatureMode);
|
||||
Assert.Contains("tiku-assets", signedUrl.Url.Host, StringComparison.Ordinal);
|
||||
Assert.Contains($"{TenantId:N}/handbook.pdf", signedUrl.Url.AbsoluteUri, StringComparison.Ordinal);
|
||||
Assert.Contains("x-oss-", signedUrl.Url.Query, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_upload_generates_aliyun_oss_presigned_put_url_with_content_type_header()
|
||||
{
|
||||
using var service = CreateConfiguredService();
|
||||
|
||||
var signedUrl = await service.SignUploadAsync(new ObjectStorageUploadSignRequest(
|
||||
TenantId,
|
||||
ObjectStorageProviders.AliyunOss,
|
||||
"tiku-assets",
|
||||
$"{TenantId:N}/upload.pdf",
|
||||
"upload.pdf",
|
||||
"application/pdf",
|
||||
1024,
|
||||
TimeSpan.FromMinutes(10)));
|
||||
|
||||
Assert.Equal("PUT", signedUrl.Method);
|
||||
Assert.Contains("x-oss-", signedUrl.Url.Query, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cdn_url_download_returns_public_or_provider_managed_url_without_oss_signing()
|
||||
{
|
||||
using var service = CreateUnconfiguredService();
|
||||
|
||||
var signedUrl = await service.SignDownloadAsync(new ObjectStorageDownloadSignRequest(
|
||||
TenantId,
|
||||
ObjectStorageProviders.ExternalUrl,
|
||||
null,
|
||||
null,
|
||||
TimeSpan.FromMinutes(5),
|
||||
"https://cdn.example.com/file.pdf"));
|
||||
|
||||
Assert.Equal("public-or-provider-managed", signedUrl.SignatureMode);
|
||||
Assert.Equal("https://cdn.example.com/file.pdf", signedUrl.Url.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aliyun_oss_options_allow_empty_optional_configuration()
|
||||
{
|
||||
Assert.True(AliyunOssOptions.BeValid(new AliyunOssOptions()));
|
||||
Assert.False(AliyunOssOptions.BeValid(new AliyunOssOptions
|
||||
{
|
||||
PresignDefaultMinutes = 0
|
||||
}));
|
||||
}
|
||||
|
||||
private static AliyunOssObjectStorageService CreateConfiguredService(ObjectStorageOptions? storageOptions = null) =>
|
||||
new(
|
||||
Options.Create(storageOptions ?? new ObjectStorageOptions()),
|
||||
Options.Create(new AliyunOssOptions
|
||||
{
|
||||
Region = "cn-hangzhou",
|
||||
AccessKeyId = "test-access-key-id",
|
||||
AccessKeySecret = "test-access-key-secret"
|
||||
}));
|
||||
|
||||
private static AliyunOssObjectStorageService CreateUnconfiguredService() =>
|
||||
new(Options.Create(new ObjectStorageOptions()), Options.Create(new AliyunOssOptions()));
|
||||
}
|
||||
Reference in New Issue
Block a user