diff --git a/Directory.Packages.props b/Directory.Packages.props
index 49ea63a..59a5902 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,6 +24,7 @@
+
diff --git a/README.md b/README.md
index 7022199..3167ec4 100644
--- a/README.md
+++ b/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`
## 常用命令
diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs
index 5a62e14..7e4c160 100644
--- a/Tiku.Api/Program.cs
+++ b/Tiku.Api/Program.cs
@@ -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(
+ builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
+ builder.Services.Configure(
+ builder.Configuration.GetSection(AliyunOssOptions.SectionName));
+ builder.Services.PostConfigure(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(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()
.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;
diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json
index 65ebbc4..2692155 100644
--- a/Tiku.Api/appsettings.Development.json
+++ b/Tiku.Api/appsettings.Development.json
@@ -23,5 +23,12 @@
"PermitLimit": 1200,
"WindowSeconds": 60,
"QueueLimit": 0
+ },
+ "Storage": {
+ "AliyunOss": {
+ "Region": "cn-hangzhou",
+ "Endpoint": "",
+ "UseInternalEndpoint": false
+ }
}
}
diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json
index 7616b9d..c4a375d 100644
--- a/Tiku.Api/appsettings.json
+++ b/Tiku.Api/appsettings.json
@@ -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",
diff --git a/Tiku.Application/Storage/IObjectStorageService.cs b/Tiku.Application/Storage/IObjectStorageService.cs
new file mode 100644
index 0000000..ab3a243
--- /dev/null
+++ b/Tiku.Application/Storage/IObjectStorageService.cs
@@ -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 SignUploadAsync(
+ ObjectStorageUploadSignRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task SignDownloadAsync(
+ ObjectStorageDownloadSignRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task HeadObjectAsync(
+ ObjectStorageHeadRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Storage/ObjectStorageContracts.cs b/Tiku.Application/Storage/ObjectStorageContracts.cs
new file mode 100644
index 0000000..7021ec5
--- /dev/null
+++ b/Tiku.Application/Storage/ObjectStorageContracts.cs
@@ -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 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 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");
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index 31f1e30..faa47f9 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -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();
services.AddScoped();
services.AddScoped();
+ services.AddOptions()
+ .Validate(
+ AliyunOssOptions.BeValid,
+ "Aliyun OSS presign default expiration must be positive.");
+ services.AddOptions()
+ .Validate(
+ ObjectStorageOptions.BeValid,
+ "Storage options must include a default provider, default bucket and positive max upload bytes.");
+ services.AddSingleton();
return services;
}
diff --git a/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
new file mode 100644
index 0000000..70a171e
--- /dev/null
+++ b/Tiku.Infrastructure/Storage/AliyunOssObjectStorageService.cs
@@ -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 storageOptions,
+ IOptions aliyunOptions)
+ : IObjectStorageService, IDisposable
+{
+ private static readonly HashSet SupportedProviders =
+ [
+ ObjectStorageProviders.ExternalUrl,
+ ObjectStorageProviders.SupabaseStorage,
+ ObjectStorageProviders.AliyunOss,
+ ObjectStorageProviders.TencentCos,
+ ObjectStorageProviders.QiniuKodo,
+ ObjectStorageProviders.LocalDev
+ ];
+
+ private static readonly HashSet 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 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(StringComparer.OrdinalIgnoreCase)
+ {
+ ["content-type"] = mimeType
+ }))
+ : Task.FromResult(SignAliyunOss(
+ request.Bucket,
+ objectKey,
+ "PUT",
+ request.ExpiresIn,
+ mimeType: mimeType,
+ fileName: null,
+ disposition: null));
+ }
+
+ public Task 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(),
+ 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 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(),
+ "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? 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(),
+ 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()
+ : new Dictionary(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())
+ {
+ 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 NormalizeHeaders(IDictionary? headers)
+ {
+ var normalized = new Dictionary(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 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();
+}
diff --git a/Tiku.Infrastructure/Storage/AliyunOssOptions.cs b/Tiku.Infrastructure/Storage/AliyunOssOptions.cs
new file mode 100644
index 0000000..916b0be
--- /dev/null
+++ b/Tiku.Infrastructure/Storage/AliyunOssOptions.cs
@@ -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;
+ }
+}
diff --git a/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs b/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
new file mode 100644
index 0000000..2e1b425
--- /dev/null
+++ b/Tiku.Infrastructure/Storage/ObjectStorageOptions.cs
@@ -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);
+}
diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj
index ddcaad4..800c076 100644
--- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj
+++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj
@@ -6,6 +6,7 @@
+
diff --git a/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs b/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs
new file mode 100644
index 0000000..944573a
--- /dev/null
+++ b/Tiku.UnitTests/Storage/AliyunOssObjectStorageServiceTests.cs
@@ -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(() =>
+ 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(() => service.ValidateMimeType("text/html")).Code);
+ Assert.Equal("FILE_TOO_LARGE", Assert.Throws(() => service.ValidateFileSize(101)).Code);
+ }
+
+ [Fact]
+ public async Task Sign_download_requires_complete_aliyun_oss_options()
+ {
+ using var service = CreateUnconfiguredService();
+
+ var exception = await Assert.ThrowsAsync(() =>
+ 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()));
+}