feat: add aliyun oss storage foundation

This commit is contained in:
xiong
2026-07-26 14:43:04 +08:00
parent 47613b51bd
commit c6051f95f2
13 changed files with 969 additions and 0 deletions

View File

@@ -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" />

View File

@@ -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`,不直接依赖阿里云 SDKSDK 细节收敛在 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`
## 常用命令

View File

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

View File

@@ -23,5 +23,12 @@
"PermitLimit": 1200,
"WindowSeconds": 60,
"QueueLimit": 0
},
"Storage": {
"AliyunOss": {
"Region": "cn-hangzhou",
"Endpoint": "",
"UseInternalEndpoint": false
}
}
}

View File

@@ -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",

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

View 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");

View File

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

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

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

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

View File

@@ -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" />

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