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

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

View File

@@ -36,6 +36,7 @@
<PackageVersion Include="StackExchange.Redis" Version="3.1.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
<PackageVersion Include="Minio" Version="7.0.0" />
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />

View File

@@ -32,17 +32,24 @@ Tiku.IntegrationTests API、授权、EF 模型、迁移和真实 PostgreSQL 测
## 快速启动
需要 .NET 10 SDK 和 PostgreSQL。Development 默认连接本机 `tiku` 数据库,也可以通过 `DATABASE_URL` 覆盖。
需要 .NET 10 SDK、Node.js 24+ 和 Docker Desktop。本地 PostgreSQL、Redis 与 S3 兼容对象存储统一由根目录的 `compose.yaml` 提供:
```bash
createdb -h 127.0.0.1 -U "$(whoami)" tiku
docker compose up -d --wait
export ConnectionStrings__Database='Host=127.0.0.1;Port=5432;Database=tiku;Username=tiku;Password=tiku_dev'
export ConnectionStrings__Redis='127.0.0.1:6379,abortConnect=false'
dotnet restore TIKU-BACKEND.slnx
dotnet build TIKU-BACKEND.slnx --no-restore
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator
dotnet run --project Tiku.Api
dotnet run --project Tiku.Worker
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.Api --launch-profile http
# 另开终端并设置相同连接串后启动 Worker
DOTNET_ENVIRONMENT=Development dotnet run --project Tiku.Worker
```
`docker compose ps` 应显示 PostgreSQL、Redis 与 MinIO 均为 `healthy`。Development 未配置阿里云 OSS 时,`local_dev` Provider 会把对象实际保存到 MinIO而不是返回占位结果。默认账号和密码只用于本机开发不能用于共享或生产环境。完整配置、停止和排障步骤见[本地开发快速上手](docs/quickstart.md)。
默认 Development seed 会在尚无平台角色绑定时创建平台管理员 `admin@tiku.local` 和演示数据;随机临时密码只在首次创建时输出。日常步骤见[本地开发快速上手](docs/quickstart.md),不含演示数据的完整 SaaS 验收见[空数据库到租户建站验收](docs/tenant-provisioning.md)。
默认开发入口:

View File

@@ -18,9 +18,15 @@ internal static class ExternalServiceOptionsExtensions
configuration.GetSection(ObjectStorageOptions.SectionName));
services.Configure<AliyunOssOptions>(
configuration.GetSection(AliyunOssOptions.SectionName));
services.Configure<S3CompatibleOptions>(
configuration.GetSection(S3CompatibleOptions.SectionName));
services.PostConfigure<ObjectStorageOptions>(options =>
{
options.DefaultProvider = configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
if (!environment.IsProduction() &&
options.DefaultProvider == ObjectStorageProviders.AliyunOss &&
!HasConfiguredAliyunOss(configuration))
options.DefaultProvider = ObjectStorageProviders.LocalDev;
options.DefaultBucket = configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
options.PublicBaseUrl = configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
options.AllowedMimePrefixes = SplitLegacyList(
@@ -53,6 +59,16 @@ internal static class ExternalServiceOptionsExtensions
? useInternalEndpoint
: options.UseInternalEndpoint;
});
services.PostConfigure<S3CompatibleOptions>(options =>
{
options.Endpoint = configuration["S3_ENDPOINT"] ?? options.Endpoint;
options.AccessKey = configuration["S3_ACCESS_KEY"] ?? options.AccessKey;
options.SecretKey = configuration["S3_SECRET_KEY"] ?? options.SecretKey;
options.Region = configuration["S3_REGION"] ?? options.Region;
options.Secure = bool.TryParse(configuration["S3_SECURE"], out var secure)
? secure
: options.Secure;
});
services.AddOptions<ObjectStorageOptions>()
.Validate(
options => !environment.IsProduction() ||
@@ -67,6 +83,12 @@ internal static class ExternalServiceOptionsExtensions
aliyun.IsConfigured,
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
.ValidateOnStart();
services.AddOptions<S3CompatibleOptions>()
.Validate<IOptions<ObjectStorageOptions>>(
(s3, storage) =>
storage.Value.DefaultProvider != ObjectStorageProviders.LocalDev || s3.IsConfigured,
"S3-compatible endpoint and credentials are required when local_dev is the default provider.")
.ValidateOnStart();
services.AddOptions<ClamAvOptions>()
.Bind(configuration.GetSection(ClamAvOptions.SectionName))
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
@@ -112,4 +134,16 @@ internal static class ExternalServiceOptionsExtensions
? fallback
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}
private static bool HasConfiguredAliyunOss(IConfiguration configuration)
{
return !string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ??
configuration["Storage:AliyunOss:AccessKeyId"]) &&
!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ??
configuration["Storage:AliyunOss:AccessKeySecret"]) &&
(!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_REGION"] ??
configuration["Storage:AliyunOss:Region"]) ||
!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ENDPOINT"] ??
configuration["Storage:AliyunOss:Endpoint"]));
}
}

View File

@@ -47,6 +47,14 @@
}
},
"Storage": {
"DefaultProvider": "local_dev",
"S3Compatible": {
"Endpoint": "127.0.0.1:9000",
"AccessKey": "tiku",
"SecretKey": "tiku_minio_dev",
"Region": "us-east-1",
"Secure": false
},
"AliyunOss": {
"Region": "cn-hangzhou",
"Endpoint": "",

View File

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

View File

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

View File

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

View File

@@ -120,6 +120,51 @@ public sealed class AliyunOssObjectStorageServiceTests
}));
}
[Fact]
public async Task Local_dev_round_trips_through_configured_s3_endpoint()
{
var endpoint = Environment.GetEnvironmentVariable("TIKU_S3_TEST_ENDPOINT");
if (string.IsNullOrWhiteSpace(endpoint)) return;
using var service = new AliyunOssObjectStorageService(
Options.Create(new ObjectStorageOptions { DefaultProvider = ObjectStorageProviders.LocalDev }),
Options.Create(new AliyunOssOptions()),
Options.Create(new S3CompatibleOptions
{
Endpoint = endpoint,
AccessKey = Environment.GetEnvironmentVariable("TIKU_S3_TEST_ACCESS_KEY") ?? "tiku",
SecretKey = Environment.GetEnvironmentVariable("TIKU_S3_TEST_SECRET_KEY") ?? "tiku_minio_dev",
Region = "us-east-1"
}));
var objectKey = $"{TenantId:N}/tests/{Guid.NewGuid():N}.json";
const string payload = "{\"storage\":\"minio\"}";
await using var input = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(payload));
var write = await service.WriteObjectAsync(new ObjectStorageWriteRequest(
TenantId,
ObjectStorageProviders.LocalDev,
"tiku-tests",
objectKey,
"application/json",
input,
input.Length));
var metadata = await service.HeadObjectAsync(new ObjectStorageHeadRequest(
TenantId,
ObjectStorageProviders.LocalDev,
"tiku-tests",
objectKey));
await using var output = await service.OpenReadAsync(new ObjectStorageReadRequest(
TenantId,
ObjectStorageProviders.LocalDev,
"tiku-tests",
objectKey));
using var reader = new StreamReader(output);
Assert.Equal("s3-compatible-put-object", write.VerificationSource);
Assert.Equal(input.Length, metadata.SizeBytes);
Assert.Equal(payload, await reader.ReadToEndAsync());
}
private static AliyunOssObjectStorageService CreateConfiguredService(ObjectStorageOptions? storageOptions = null)
{
return new AliyunOssObjectStorageService(
@@ -129,12 +174,20 @@ public sealed class AliyunOssObjectStorageServiceTests
Region = "cn-hangzhou",
AccessKeyId = "test-access-key-id",
AccessKeySecret = "test-access-key-secret"
}),
Options.Create(new S3CompatibleOptions
{
Endpoint = "127.0.0.1:9000",
AccessKey = "test-access-key",
SecretKey = "test-secret-key",
Region = "us-east-1"
}));
}
private static AliyunOssObjectStorageService CreateUnconfiguredService()
{
return new AliyunOssObjectStorageService(Options.Create(new ObjectStorageOptions()),
Options.Create(new AliyunOssOptions()));
Options.Create(new AliyunOssOptions()),
Options.Create(new S3CompatibleOptions()));
}
}

View File

@@ -31,6 +31,8 @@ internal static class WorkerDependencyInjection
: throw new InvalidOperationException(
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
builder.Services.AddApplication();
builder.Services.AddAuthentication();
builder.Services.AddDataProtection();
builder.Services.AddInfrastructure(connectionString);
var redisConnectionString =
builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
@@ -49,9 +51,15 @@ internal static class WorkerDependencyInjection
builder.Services.Configure<ObjectStorageOptions>(
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
builder.Services.Configure<AliyunOssOptions>(builder.Configuration.GetSection(AliyunOssOptions.SectionName));
builder.Services.Configure<S3CompatibleOptions>(
builder.Configuration.GetSection(S3CompatibleOptions.SectionName));
builder.Services.PostConfigure<ObjectStorageOptions>(options =>
{
options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
if (!builder.Environment.IsProduction() &&
options.DefaultProvider == ObjectStorageProviders.AliyunOss &&
!HasConfiguredAliyunOss(builder.Configuration))
options.DefaultProvider = ObjectStorageProviders.LocalDev;
options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
options.AllowedMimePrefixes = SplitLegacyList(
@@ -84,6 +92,16 @@ internal static class WorkerDependencyInjection
? useInternalEndpoint
: options.UseInternalEndpoint;
});
builder.Services.PostConfigure<S3CompatibleOptions>(options =>
{
options.Endpoint = builder.Configuration["S3_ENDPOINT"] ?? options.Endpoint;
options.AccessKey = builder.Configuration["S3_ACCESS_KEY"] ?? options.AccessKey;
options.SecretKey = builder.Configuration["S3_SECRET_KEY"] ?? options.SecretKey;
options.Region = builder.Configuration["S3_REGION"] ?? options.Region;
options.Secure = bool.TryParse(builder.Configuration["S3_SECURE"], out var secure)
? secure
: options.Secure;
});
builder.Services.AddOptions<ObjectStorageOptions>()
.Validate(
options => !builder.Environment.IsProduction() ||
@@ -98,6 +116,12 @@ internal static class WorkerDependencyInjection
aliyun.IsConfigured,
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
.ValidateOnStart();
builder.Services.AddOptions<S3CompatibleOptions>()
.Validate<IOptions<ObjectStorageOptions>>(
(s3, storage) =>
storage.Value.DefaultProvider != ObjectStorageProviders.LocalDev || s3.IsConfigured,
"S3-compatible endpoint and credentials are required when local_dev is the default provider.")
.ValidateOnStart();
builder.Services.AddOptions<ClamAvOptions>()
.Bind(builder.Configuration.GetSection(ClamAvOptions.SectionName))
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
@@ -137,4 +161,16 @@ internal static class WorkerDependencyInjection
? fallback
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}
private static bool HasConfiguredAliyunOss(IConfiguration configuration)
{
return !string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ??
configuration["Storage:AliyunOss:AccessKeyId"]) &&
!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ??
configuration["Storage:AliyunOss:AccessKeySecret"]) &&
(!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_REGION"] ??
configuration["Storage:AliyunOss:Region"]) ||
!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ENDPOINT"] ??
configuration["Storage:AliyunOss:Endpoint"]));
}
}

View File

@@ -2,6 +2,7 @@ using System.Data;
using System.Diagnostics;
using Microsoft.Extensions.Options;
using Npgsql;
using NpgsqlTypes;
using Tiku.Application.Jobs;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.PlatformBilling;
@@ -80,7 +81,7 @@ internal sealed class WorkerStateReporter(NpgsqlDataSource dataSource)
command.Parameters.AddWithValue("started_at", startedAt);
command.Parameters.AddWithValue("now", now);
command.Parameters.AddWithValue("running", running);
command.Parameters.AddWithValue("error", (object?)error ?? DBNull.Value);
command.Parameters.Add("error", NpgsqlDbType.Text).Value = (object?)error ?? DBNull.Value;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -3,5 +3,15 @@
"MinimumLevel": {
"Default": "Debug"
}
},
"Storage": {
"DefaultProvider": "local_dev",
"S3Compatible": {
"Endpoint": "127.0.0.1:9000",
"AccessKey": "tiku",
"SecretKey": "tiku_minio_dev",
"Region": "us-east-1",
"Secure": false
}
}
}

View File

@@ -34,6 +34,26 @@ services:
retries: 10
start_period: 5s
minio:
image: minio/minio:latest
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: ${TIKU_MINIO_ROOT_USER:-tiku}
MINIO_ROOT_PASSWORD: ${TIKU_MINIO_ROOT_PASSWORD:-tiku_minio_dev}
ports:
- "127.0.0.1:${TIKU_MINIO_API_PORT:-9000}:9000"
- "127.0.0.1:${TIKU_MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
postgres_data:
redis_data:
minio_data:

View File

@@ -100,6 +100,6 @@ API 不自动迁移数据库。
## 外部服务边界
Application 通过接口表达身份、短信、对象存储、支付、通知、域名和 AI 能力Infrastructure 当前包含自托管身份、阿里云短信/OSS、微信、支付宝、站内通知、DNS JSON 查询和 HTTP 网关实现。
Application 通过接口表达身份、短信、对象存储、支付、通知、域名和 AI 能力Infrastructure 当前包含自托管身份、阿里云短信/OSS、S3 兼容本地对象存储、微信、支付宝、站内通知、DNS JSON 查询和 HTTP 网关实现。
租户级 Provider 元数据和密钥分别存入 `TenantExternalProvider``TenantSecret`。密钥由 32 字节 master key 加密API 不应把明文、`SecretRef` 或 Provider 内部 payload 返回给客户端。

View File

@@ -160,7 +160,7 @@ Production 启动至少需要核对:
## 对象存储与外部 Provider
对象存储读取 `Storage` / `Storage:AliyunOss`API 与 Worker 支持 `STORAGE_*``ALIYUN_OSS_*` 环境变量。当前默认实现是阿里云 OSS强制租户 key 前缀、上传大小和 MIME allowlist。租户导出使用 `application/gzip`,该类型不能从 allowlist 删除。
对象存储读取 `Storage``Storage:AliyunOss``Storage:S3Compatible`API 与 Worker 支持 `STORAGE_*``ALIYUN_OSS_*` 以及 `S3_ENDPOINT``S3_ACCESS_KEY``S3_SECRET_KEY``S3_REGION``S3_SECURE` 环境变量。Production 默认并强制使用已配置的阿里云 OSS非 Production 没有完整 OSS 凭据时回退到 `local_dev`,该 Provider 使用 S3 兼容存储,快速部署环境由 MinIO 提供。所有托管 Provider 都强制租户 key 前缀、上传大小和 MIME allowlist。租户导出使用 `application/gzip`,该类型不能从 allowlist 删除。
## ClamAV 资源安全扫描

View File

@@ -6,14 +6,14 @@
- .NET 10 SDK
- Node.js 24+、npm 11+
- PostgreSQL
- RedisDevelopment 可不配置;涉及分布式缓存、频控或完整验收时应启动)
- Docker Desktop通过 Docker Compose 提供 PostgreSQL 18、Redis 7 和 MinIO
```bash
dotnet --version
node --version
npm --version
pg_isready -h 127.0.0.1 -p 5432
docker info
docker compose version
```
首次拉取代码后恢复依赖:
@@ -23,27 +23,54 @@ dotnet restore TIKU-BACKEND.slnx
npm --prefix Tiku.PlatformAdmin.Web install
```
## 配置数据库
## 启动 PostgreSQL、Redis 与对象存储
Development 未配置连接串时,默认使用当前系统用户连接本机 `tiku` 数据库
在仓库根目录启动依赖并等待健康检查通过
```bash
docker compose up -d --wait
docker compose ps
docker compose exec postgres pg_isready -U tiku -d tiku
docker compose exec redis redis-cli ping
curl --fail http://127.0.0.1:9000/minio/health/live
```
默认只监听本机回环地址:
```text
Host=localhost;Database=tiku;Username=<当前系统用户>
PostgreSQL: 127.0.0.1:5432数据库 tiku用户 tiku密码 tiku_dev
Redis: 127.0.0.1:6379
MinIO API: 127.0.0.1:9000Access Key tikuSecret Key tiku_minio_dev
MinIO 控制台: http://127.0.0.1:9001
```
首次使用可创建数据库
为 DbMigrator、API 和 Worker 设置相同的连接串
```bash
createdb -h 127.0.0.1 -U <数据库用户> tiku
export ConnectionStrings__Database='Host=127.0.0.1;Port=5432;Database=tiku;Username=tiku;Password=tiku_dev'
export ConnectionStrings__Redis='127.0.0.1:6379,abortConnect=false'
```
使用其他地址、端口或账号时,通过环境变量覆盖:
Development 配置会把 `local_dev` 对象存储指向该 MinIO没有完整阿里云 OSS 凭据时也会自动回退到 `local_dev`。文件内容保存在 MinIO 数据卷PostgreSQL 只保存业务元数据和对象引用。
这些默认凭据仅用于本机开发不能用于共享或生产环境。需要自定义数据库、MinIO 凭据或宿主机端口时,应在首次创建数据卷前设置 Compose 变量,并同步修改应用连接串或 `Storage:S3Compatible` 配置:
```bash
export ConnectionStrings__Database='Host=127.0.0.1;Port=5432;Database=tiku;Username=<数据库用户>;Password=<本地密码>'
export TIKU_POSTGRES_DB='<数据库名>'
export TIKU_POSTGRES_USER='<数据库用户>'
export TIKU_POSTGRES_PASSWORD='<本地密码>'
export TIKU_POSTGRES_PORT='<宿主机端口>'
export TIKU_REDIS_PORT='<宿主机端口>'
export TIKU_MINIO_ROOT_USER='<Access Key>'
export TIKU_MINIO_ROOT_PASSWORD='<Secret Key>'
export TIKU_MINIO_API_PORT='<宿主机 API 端口>'
export TIKU_MINIO_CONSOLE_PORT='<宿主机控制台端口>'
docker compose up -d --wait
```
不要把连接串、密码、Token 或私钥写入仓库。
PostgreSQL 初始化变量不会修改已有数据卷中的账号或数据库。不要把自定义连接串、密码、Token 或私钥写入仓库。
日常暂停使用 `docker compose stop`,恢复使用 `docker compose start``docker compose down` 会删除容器和网络但保留命名数据卷;`docker compose down -v` 会永久删除本地 PostgreSQL、Redis 与 MinIO 数据,只能在明确不需要数据时使用。
## 迁移与初始化
@@ -90,14 +117,10 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.Api --launch-profil
后台循环不在 API 内运行。需要处理域名、订阅、任务队列、授权缓存失效或商业账务时,另开终端启动 Worker
```bash
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.Worker
DOTNET_ENVIRONMENT=Development dotnet run --project Tiku.Worker
```
如需 RedisAPI 与 Worker 应使用同一实例:
```bash
export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
```
API 与 Worker 必须使用前面设置的同一 PostgreSQL、Redis 和对象存储。Worker 是通用 Host环境名使用 `DOTNET_ENVIRONMENT`;每个新终端都需要重新设置连接串,或通过本机未跟踪的安全配置注入。
## 验证修改
@@ -119,7 +142,7 @@ PostgreSQL 特有的 Migration、事务、约束和租户隔离行为必须由
### API 报数据库不可用
确认 PostgreSQL 已启动、数据库存在,并核对 `ConnectionStrings__Database``DATABASE_URL`非 Development 环境没有本地默认连接串。
先运行 `docker compose ps`,确认 PostgreSQL 为 `healthy`,再核对 `ConnectionStrings__Database``DATABASE_URL`。如果 `5432` 端口已被本机 PostgreSQL 占用,应停止该服务或通过 `TIKU_POSTGRES_PORT` 改用其他宿主机端口;非 Development 环境没有本地默认连接串。
### 找不到平台管理员临时密码
@@ -129,6 +152,10 @@ PostgreSQL 特有的 Migration、事务、约束和租户隔离行为必须由
`/api/system/health/ready` 会检查 PostgreSQL 和已配置的 Redis。先验证数据库连接配置 Redis 后还需确认 Redis 可访问。匿名响应不会暴露依赖详情。
### 上传或导出提示对象存储不可用
运行 `docker compose ps` 并确认 MinIO 为 `healthy`,再检查 `http://127.0.0.1:9000/minio/health/live`。自定义 MinIO 凭据或端口后,必须同步设置 `Storage:S3Compatible``S3_ENDPOINT``S3_ACCESS_KEY``S3_SECRET_KEY``S3_SECURE`
### API 启动了但后台任务不执行
Production 和常规 Development 都需要独立运行 `Tiku.Worker`。Development 仅额外在 API 中注册本地域名生命周期旁路,不代表 API 承载全部 Worker 循环。