feat: strengthen P0 security and operations
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitignore
|
||||
.codegraph
|
||||
.idea
|
||||
.vscode
|
||||
**/.DS_Store
|
||||
**/bin
|
||||
**/obj
|
||||
**/TestResults
|
||||
**/node_modules
|
||||
Tiku.PlatformAdmin.Web
|
||||
tools/performance/results
|
||||
10
README.md
10
README.md
@@ -1,6 +1,6 @@
|
||||
# TIKU Backend
|
||||
|
||||
TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据,由同一个 API 进程提供平台端、租户端和学生端接口并处理后台任务。
|
||||
TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据。`Tiku.Api` 提供平台端、租户端和学生端接口,`Tiku.Worker` 独立处理周期任务与 PostgreSQL 后台任务。
|
||||
|
||||

|
||||
|
||||
@@ -18,7 +18,8 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core
|
||||
## 解决方案结构
|
||||
|
||||
```text
|
||||
Tiku.Api HTTP API、中间件、认证授权、OpenAPI/Scalar 和 Hosted Service
|
||||
Tiku.Api HTTP API、中间件、认证授权和 OpenAPI/Scalar
|
||||
Tiku.Worker 域名、订阅、用量校准、导出和安全扫描等后台处理
|
||||
Tiku.Application 用例契约、应用服务接口和安全上下文
|
||||
Tiku.Domain 领域实体、枚举和值对象
|
||||
Tiku.Infrastructure EF Core、PostgreSQL、认证、后台任务和外部服务实现
|
||||
@@ -27,7 +28,7 @@ Tiku.UnitTests 单元测试
|
||||
Tiku.IntegrationTests API、授权、EF 模型、迁移和真实 PostgreSQL 测试
|
||||
```
|
||||
|
||||
依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 是唯一运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
|
||||
依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 与 `Worker` 是彼此独立的运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -39,6 +40,7 @@ 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
|
||||
```
|
||||
|
||||
Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临时密码只在 DbMigrator 首次运行的终端输出。完整步骤见[本地开发与运行](docs/quickstart.md)。
|
||||
@@ -54,6 +56,8 @@ Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临
|
||||
## 运行时边界
|
||||
|
||||
- API 不自动执行数据库迁移;部署和本地初始化都使用 `Tiku.DbMigrator`。
|
||||
- API 不运行后台循环;生产环境必须独立部署至少一个 `Tiku.Worker` 实例。
|
||||
- 多 Worker 实例通过 PostgreSQL advisory lock、任务租约和 `FOR UPDATE SKIP LOCKED` 协调。
|
||||
- Development 可不配置 Redis;Production 缺少 Redis 时 API 会拒绝启动。
|
||||
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code` 或 `tenantCode` 指定租户。
|
||||
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<Project Path="Tiku.DbMigrator/Tiku.DbMigrator.csproj" />
|
||||
<Project Path="Tiku.Domain/Tiku.Domain.csproj" />
|
||||
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj" />
|
||||
<Project Path="Tiku.Worker/Tiku.Worker.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="Tiku.IntegrationTests/Tiku.IntegrationTests.csproj" />
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.BackgroundProcessing;
|
||||
|
||||
public sealed class BackgroundProcessingOptions
|
||||
{
|
||||
public const string SectionName = "BackgroundProcessing";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int JobPollSeconds { get; set; } = 2;
|
||||
public int JobParallelism { get; set; } = 4;
|
||||
public int JobBatchSize { get; set; } = 5;
|
||||
|
||||
public static bool BeValid(BackgroundProcessingOptions options) =>
|
||||
options.JobPollSeconds is >= 1 and <= 3600 &&
|
||||
options.JobParallelism is >= 1 and <= 32 &&
|
||||
options.JobBatchSize is >= 1 and <= 100;
|
||||
}
|
||||
|
||||
internal abstract class PeriodicBackgroundService(
|
||||
ILogger logger,
|
||||
TimeSpan interval,
|
||||
bool enabled) : BackgroundService
|
||||
{
|
||||
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static void InitializeSystem(IServiceProvider services, string reason) =>
|
||||
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
|
||||
}
|
||||
|
||||
internal sealed class TenantDomainBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<TenantDomainBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<SaasSubscriptionBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(60), options.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<FeatureUsageReconciliationOptions> featureOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<FeatureUsageBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobsBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<BackgroundJobsBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
private readonly int parallelism = options.Value.JobParallelism;
|
||||
private readonly int batchSize = options.Value.JobBatchSize;
|
||||
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workers = Enumerable.Range(0, parallelism)
|
||||
.Select(index => ProcessPartitionAsync(index, cancellationToken));
|
||||
return (await Task.WhenAll(workers)).Sum();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPartitionAsync(int index, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Background job lease service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
$"{workerId}:{index}",
|
||||
batchSize,
|
||||
includeImmediateJobs: true,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@ using Tiku.Application;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Api.Caching;
|
||||
using Tiku.Api.BackgroundProcessing;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
@@ -75,21 +73,6 @@ public static class DependencyInjection
|
||||
});
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.AddOptions<BackgroundProcessingOptions>()
|
||||
.Bind(builder.Configuration.GetSection(BackgroundProcessingOptions.SectionName))
|
||||
.Validate(BackgroundProcessingOptions.BeValid, "Background processing settings are invalid.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
|
||||
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
|
||||
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
|
||||
builder.Services.AddHostedService<TenantDomainBackgroundService>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionBackgroundService>();
|
||||
builder.Services.AddHostedService<FeatureUsageBackgroundService>();
|
||||
builder.Services.AddHostedService<BackgroundJobsBackgroundService>();
|
||||
|
||||
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiAuthenticationAndAuthorization(builder.Configuration, builder.Environment);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Infrastructure.Commerce;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -50,6 +51,27 @@ internal static class ExternalServiceOptionsExtensions
|
||||
? useInternalEndpoint
|
||||
: options.UseInternalEndpoint;
|
||||
});
|
||||
services.AddOptions<ObjectStorageOptions>()
|
||||
.Validate(
|
||||
options => !environment.IsProduction() ||
|
||||
options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
"Production managed storage must use the configured Aliyun OSS provider.")
|
||||
.ValidateOnStart();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
|
||||
(aliyun, storage) =>
|
||||
!environment.IsProduction() ||
|
||||
storage.Value.DefaultProvider != Tiku.Application.Storage.ObjectStorageProviders.AliyunOss ||
|
||||
aliyun.IsConfigured,
|
||||
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
|
||||
.ValidateOnStart();
|
||||
services.AddOptions<ClamAvOptions>()
|
||||
.Bind(configuration.GetSection(ClamAvOptions.SectionName))
|
||||
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
|
||||
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
|
||||
(clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes,
|
||||
"ClamAV StreamMaxLength must be greater than or equal to the storage max upload size.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<TenantSecretEncryptionOptions>()
|
||||
.Bind(configuration.GetSection(TenantSecretEncryptionOptions.SectionName))
|
||||
|
||||
@@ -28,7 +28,7 @@ internal static class ObservabilityExtensions
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
|
||||
|
||||
return services;
|
||||
|
||||
@@ -268,3 +268,71 @@ public sealed class RequiredPasswordChangeDto
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 请求租户短信密码重置验证码。
|
||||
/// </summary>
|
||||
public sealed class PasswordResetSmsSendDto
|
||||
{
|
||||
/// <summary>租户编码;使用租户自定义域名时可省略。</summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>绑定到账号的手机号。</summary>
|
||||
[Required, StringLength(32)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>客户端设备标识,用于安全频控。</summary>
|
||||
[StringLength(256)]
|
||||
public string? DeviceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用短信验证码重置租户账号密码。
|
||||
/// </summary>
|
||||
public sealed class PasswordResetDto
|
||||
{
|
||||
/// <summary>租户编码;使用租户自定义域名时可省略。</summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>绑定到账号的手机号。</summary>
|
||||
[Required, StringLength(32)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>短信验证码。</summary>
|
||||
[Required, StringLength(12, MinimumLength = 4)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>符合当前密码策略的新密码。</summary>
|
||||
[Required, StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 已登录用户修改密码。
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedPasswordChangeDto
|
||||
{
|
||||
/// <summary>当前密码。</summary>
|
||||
[Required, StringLength(128, MinimumLength = 1)]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>符合当前密码策略的新密码。</summary>
|
||||
[Required, StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理员为用户设置一次性临时密码。
|
||||
/// </summary>
|
||||
public sealed class AdministrativePasswordResetDto
|
||||
{
|
||||
/// <summary>符合当前密码策略的临时密码。</summary>
|
||||
[Required, StringLength(128, MinimumLength = 12)]
|
||||
public string TemporaryPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>审计原因。</summary>
|
||||
[Required, StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed class CreateBackgroundJobDto
|
||||
/// 最大重试次数。
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
/// <summary>同租户同任务类型内的可选幂等键。</summary>
|
||||
public string? IdempotencyKey { get; set; }
|
||||
|
||||
public CreateBackgroundJobCommand ToCommand(Guid tenantId)
|
||||
{
|
||||
@@ -32,6 +34,16 @@ public sealed class CreateBackgroundJobDto
|
||||
JobType,
|
||||
Payload.ValueKind == JsonValueKind.Undefined ? JsonSerializer.SerializeToElement(new { }) : Payload,
|
||||
RunAfter,
|
||||
MaxRetries);
|
||||
MaxRetries,
|
||||
IdempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台任务取消请求。</summary>
|
||||
public sealed class CancelBackgroundJobDto
|
||||
{
|
||||
/// <summary>取消原因。</summary>
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
23
Tiku.Api/Contracts/TenantLifecycleDtos.cs
Normal file
23
Tiku.Api/Contracts/TenantLifecycleDtos.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>租户生命周期变更原因。</summary>
|
||||
public sealed class TenantLifecycleReasonDto
|
||||
{
|
||||
/// <summary>审计原因。</summary>
|
||||
[Required, StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>租户所有者转移请求。</summary>
|
||||
public sealed class TenantOwnerTransferDto
|
||||
{
|
||||
/// <summary>新的所有者用户 ID;必须是现有活跃成员。</summary>
|
||||
[Required]
|
||||
public Guid TargetUserId { get; set; }
|
||||
|
||||
/// <summary>审计原因。</summary>
|
||||
[Required, StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -230,6 +230,78 @@ public sealed class AuthController(
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("password/reset/sms/send")]
|
||||
[EndpointSummary("发送密码重置短信验证码")]
|
||||
[EndpointDescription("仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。")]
|
||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authService.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/reset")]
|
||||
[EndpointSummary("使用短信验证码重置密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = await ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authService.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
request.Code,
|
||||
request.NewPassword,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/change")]
|
||||
[EndpointSummary("已登录用户修改密码")]
|
||||
[EndpointDescription("修改成功后撤销旧会话并返回新的令牌对。")]
|
||||
public async Task<ActionResult<AuthenticationResultDto>> ChangePassword(
|
||||
AuthenticatedPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await authService.ChangePasswordAsync(
|
||||
new AuthenticatedPasswordChangeRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
private void ResolveRefreshTokenTenant(string refreshToken)
|
||||
{
|
||||
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
public sealed class BackgroundJobsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ITenantContext tenantContext) : ControllerBase
|
||||
ITenantContext tenantContext,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户后台任务")]
|
||||
@@ -37,8 +38,38 @@ public sealed class BackgroundJobsController(
|
||||
return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("{jobId:guid}")]
|
||||
[EndpointSummary("查询租户后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Detail(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobService.GetAsync(jobId, ResolveTenantId(), cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost("{jobId:guid}/cancel")]
|
||||
[EndpointSummary("取消租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Cancel(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RequestCancellationAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), request.Reason, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{jobId:guid}/retry")]
|
||||
[EndpointSummary("重试失败或已取消的租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Retry(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RetryAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), cancellationToken));
|
||||
}
|
||||
|
||||
private Guid ResolveTenantId()
|
||||
{
|
||||
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved.");
|
||||
}
|
||||
|
||||
private Guid ResolveUserId() =>
|
||||
currentUser.UserId ?? throw new InvalidOperationException("Current user was not resolved.");
|
||||
}
|
||||
|
||||
@@ -171,6 +171,79 @@ public sealed class BrowserAuthController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("password/reset/sms/send")]
|
||||
[EndpointSummary("发送浏览器密码重置短信验证码")]
|
||||
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authService.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/reset")]
|
||||
[EndpointSummary("使用短信验证码重置浏览器账号密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authService.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
request.Code,
|
||||
request.NewPassword,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/change")]
|
||||
[EndpointSummary("浏览器已登录用户修改密码")]
|
||||
public async Task<ActionResult<object>> ChangePassword(
|
||||
AuthenticatedPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await authService.ChangePasswordAsync(
|
||||
new AuthenticatedPasswordChangeRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
private object WriteResult(AuthenticationResult result)
|
||||
{
|
||||
if (result.User?.Tokens is { } tokens)
|
||||
|
||||
@@ -35,13 +35,7 @@ public sealed class HealthController(
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var ready = database && redis;
|
||||
var response = new
|
||||
{
|
||||
status = ready ? "ready" : "not_ready",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var response = new { status = ready ? "ready" : "not_ready", checkedAt = DateTimeOffset.UtcNow };
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -13,6 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/me")]
|
||||
public sealed class MeController(
|
||||
ICurrentUser currentUser,
|
||||
IAuthSessionStore sessionStore,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -54,6 +56,36 @@ public sealed class MeController(
|
||||
user.Name,
|
||||
memberships));
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[EndpointSummary("查询当前授权域的登录设备")]
|
||||
[ProducesResponseType<IReadOnlyCollection<AuthSessionSummary>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<AuthSessionSummary>>> Sessions(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(await sessionStore.ListActiveAsync(userId, sessionId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{sessionFamilyId:guid}")]
|
||||
[EndpointSummary("撤销其他设备的登录会话")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RevokeSession(
|
||||
Guid sessionFamilyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
await sessionStore.RevokeOwnedFamilyAsync(userId, sessionId, sessionFamilyId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
@@ -14,6 +15,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/platform-admin")]
|
||||
public sealed class PlatformAdminController(
|
||||
IPlatformAdminService platformAdminService,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
@@ -134,6 +136,27 @@ public sealed class PlatformAdminController(
|
||||
return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("staff/{userId:guid}/password-reset")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("为平台员工设置一次性临时密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetStaffPassword(
|
||||
Guid userId,
|
||||
AdministrativePasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = ResolveActor();
|
||||
await authAdministrationService.ResetPasswordAsync(
|
||||
new AdministrativePasswordResetRequest(
|
||||
actor.UserId,
|
||||
userId,
|
||||
null,
|
||||
request.TemporaryPassword,
|
||||
request.Reason),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计日志")]
|
||||
|
||||
159
Tiku.Api/Controllers/PlatformOperationsController.cs
Normal file
159
Tiku.Api/Controllers/PlatformOperationsController.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-运维")]
|
||||
[Route("api/platform-admin/operations")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsView)]
|
||||
public sealed class PlatformOperationsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
IAssetSecurityScanner assetSecurityScanner,
|
||||
IObjectStorageService objectStorageService,
|
||||
IOptions<AliyunOssOptions> aliyunOssOptions) : ControllerBase
|
||||
{
|
||||
[HttpGet("health")]
|
||||
[EndpointSummary("查询受保护的依赖深度健康状态")]
|
||||
public async Task<ActionResult<object>> Health(CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var clamAv = await assetSecurityScanner.CheckHealthAsync(cancellationToken);
|
||||
var storageProvider = objectStorageService.ConfiguredDefaultProvider();
|
||||
var storageConfigured = storageProvider switch
|
||||
{
|
||||
ObjectStorageProviders.AliyunOss => aliyunOssOptions.Value.IsConfigured,
|
||||
ObjectStorageProviders.LocalDev => true,
|
||||
_ => false
|
||||
};
|
||||
var newestHeartbeat = await dbContext.WorkerHeartbeats.AsNoTracking()
|
||||
.MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken);
|
||||
var workerReady = newestHeartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2);
|
||||
return Ok(new
|
||||
{
|
||||
status = database && redis && clamAv && workerReady && storageConfigured ? "healthy" : "degraded",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
worker = new { ready = workerReady, lastHeartbeatAt = newestHeartbeat },
|
||||
clamAv,
|
||||
storage = new { provider = storageProvider, configured = storageConfigured },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("workers")]
|
||||
[EndpointSummary("查询 Worker 与周期循环状态")]
|
||||
public async Task<ActionResult<object>> Workers(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var items = await dbContext.WorkerHeartbeats.AsNoTracking()
|
||||
.OrderBy(item => item.WorkerId)
|
||||
.ThenBy(item => item.Processor)
|
||||
.Select(item => new
|
||||
{
|
||||
item.WorkerId,
|
||||
item.Processor,
|
||||
item.StartedAt,
|
||||
item.LastHeartbeatAt,
|
||||
item.LastIterationStartedAt,
|
||||
item.LastIterationCompletedAt,
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
staleAfterSeconds = 120,
|
||||
items = items.Select(item => new
|
||||
{
|
||||
item.WorkerId,
|
||||
item.Processor,
|
||||
item.StartedAt,
|
||||
item.LastHeartbeatAt,
|
||||
item.LastIterationStartedAt,
|
||||
item.LastIterationCompletedAt,
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning,
|
||||
stale = item.LastHeartbeatAt < now.AddMinutes(-2)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("job-metrics")]
|
||||
[EndpointSummary("查询后台任务队列指标")]
|
||||
public async Task<ActionResult<object>> JobMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var counts = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new { status = group.Key, count = group.Count() })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var oldestPending = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.Where(item => item.Status == BackgroundJobStatus.Pending)
|
||||
.MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken);
|
||||
var expiredLeases = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
counts,
|
||||
oldestPendingAt = oldestPending,
|
||||
queueAgeSeconds = oldestPending.HasValue ? Math.Max(0, (now - oldestPending.Value).TotalSeconds) : 0,
|
||||
expiredLeases,
|
||||
checkedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
[EndpointSummary("查询全平台后台任务")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> Jobs(
|
||||
[FromQuery] Guid? tenantId,
|
||||
[FromQuery] string? jobType,
|
||||
[FromQuery] BackgroundJobStatus? status,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await backgroundJobService.ListPlatformAsync(
|
||||
tenantId, jobType, status, limit ?? 100, cancellationToken));
|
||||
|
||||
[HttpGet("jobs/{jobId:guid}")]
|
||||
[EndpointSummary("查询平台后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Job(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobService.GetAsync(jobId, null, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{jobId:guid}/cancel")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
||||
[EndpointSummary("取消平台后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> CancelJob(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await backgroundJobService.RequestCancellationAsync(
|
||||
jobId, null, ResolveUserId(), request.Reason, cancellationToken));
|
||||
|
||||
[HttpPost("jobs/{jobId:guid}/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
||||
[EndpointSummary("重试平台后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> RetryJob(Guid jobId, CancellationToken cancellationToken) =>
|
||||
Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken));
|
||||
|
||||
private Guid ResolveUserId() =>
|
||||
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
|
||||
}
|
||||
76
Tiku.Api/Controllers/PlatformTenantLifecycleController.cs
Normal file
76
Tiku.Api/Controllers/PlatformTenantLifecycleController.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-租户生命周期")]
|
||||
[Route("api/platform-admin/tenants/{tenantId:guid}")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
public sealed class PlatformTenantLifecycleController(
|
||||
ITenantLifecycleService lifecycleService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("archive-preview")]
|
||||
[EndpointSummary("预检租户归档条件")]
|
||||
public async Task<ActionResult<TenantArchivePreview>> Preview(Guid tenantId, CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.PreviewArchiveAsync(tenantId, cancellationToken));
|
||||
|
||||
[HttpPost("exports")]
|
||||
[EndpointSummary("创建租户数据导出")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> CreateExport(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Accepted(await lifecycleService.CreateExportAsync(tenantId, ResolveUserId(), cancellationToken));
|
||||
|
||||
[HttpGet("exports/{operationId:guid}")]
|
||||
[EndpointSummary("查询租户数据导出状态")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> ExportStatus(
|
||||
Guid tenantId,
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await lifecycleService.GetOperationAsync(tenantId, operationId, cancellationToken);
|
||||
return operation is null ? NotFound() : Ok(operation);
|
||||
}
|
||||
|
||||
[HttpGet("exports/{operationId:guid}/download")]
|
||||
[EndpointSummary("获取租户导出下载地址")]
|
||||
public async Task<ActionResult<ObjectStorageSignedUrl>> DownloadExport(
|
||||
Guid tenantId,
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.SignExportDownloadAsync(tenantId, operationId, cancellationToken));
|
||||
|
||||
[HttpPost("archive")]
|
||||
[EndpointSummary("逻辑归档租户")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> Archive(
|
||||
Guid tenantId,
|
||||
TenantLifecycleReasonDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.ArchiveAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
|
||||
|
||||
[HttpPost("restore")]
|
||||
[EndpointSummary("恢复租户到暂停状态")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> Restore(
|
||||
Guid tenantId,
|
||||
TenantLifecycleReasonDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.RestoreAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
|
||||
|
||||
[HttpPost("owner-transfer")]
|
||||
[EndpointSummary("转移租户所有者")]
|
||||
public async Task<ActionResult<TenantLifecycleOperationItem>> TransferOwner(
|
||||
Guid tenantId,
|
||||
TenantOwnerTransferDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await lifecycleService.TransferOwnerAsync(
|
||||
tenantId, ResolveUserId(), request.TargetUserId, request.Reason, cancellationToken));
|
||||
|
||||
private Guid ResolveUserId() =>
|
||||
currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
|
||||
}
|
||||
@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -15,6 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/tenant-admin")]
|
||||
public sealed class TenantAdminDirectController(
|
||||
ITenantAdminDirectService tenantAdminService,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
@@ -314,6 +317,27 @@ public sealed class TenantAdminDirectController(
|
||||
return Ok(await tenantAdminService.DisableMemberAsync(ResolveActor(), request.MembershipId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("members/{userId:guid}/password-reset")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("为租户成员设置一次性临时密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetMemberPassword(
|
||||
Guid userId,
|
||||
AdministrativePasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = ResolveActor();
|
||||
await authAdministrationService.ResetPasswordAsync(
|
||||
new AdministrativePasswordResetRequest(
|
||||
actor.UserId,
|
||||
userId,
|
||||
actor.TenantId,
|
||||
request.TemporaryPassword,
|
||||
request.Reason),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStaffManage)]
|
||||
[EndpointSummary("查询租户审计日志")]
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["TIKU-BACKEND/TIKU-BACKEND.csproj", "TIKU-BACKEND/"]
|
||||
RUN dotnet restore "TIKU-BACKEND/TIKU-BACKEND.csproj"
|
||||
COPY ["Directory.Build.props", "Directory.Packages.props", "./"]
|
||||
COPY ["Tiku.Domain/Tiku.Domain.csproj", "Tiku.Domain/"]
|
||||
COPY ["Tiku.Application/Tiku.Application.csproj", "Tiku.Application/"]
|
||||
COPY ["Tiku.Infrastructure/Tiku.Infrastructure.csproj", "Tiku.Infrastructure/"]
|
||||
COPY ["Tiku.Api/Tiku.Api.csproj", "Tiku.Api/"]
|
||||
RUN dotnet restore "Tiku.Api/Tiku.Api.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/TIKU-BACKEND"
|
||||
RUN dotnet build "./TIKU-BACKEND.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./TIKU-BACKEND.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
RUN dotnet publish "Tiku.Api/Tiku.Api.csproj" \
|
||||
-c $BUILD_CONFIGURATION \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "TIKU-BACKEND.dll"]
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Tiku.Api.dll"]
|
||||
|
||||
@@ -6,6 +6,7 @@ using Tiku.Application.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
@@ -51,6 +52,20 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantLifecycleException lifecycleException)
|
||||
{
|
||||
var status = lifecycleException.Code switch
|
||||
{
|
||||
"tenant_not_found" => StatusCodes.Status404NotFound,
|
||||
"tenant_export_not_ready" => StatusCodes.Status409Conflict,
|
||||
"tenant_archive_blocked" or "tenant_not_archived" or
|
||||
"tenant_owner_target_not_active_member" or "tenant_owner_unchanged" => StatusCodes.Status409Conflict,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
await WriteProblemAsync(context, lifecycleException.Message, status, lifecycleException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is BrowserOriginException)
|
||||
{
|
||||
await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected");
|
||||
@@ -69,6 +84,22 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is BackgroundJobException backgroundJobException)
|
||||
{
|
||||
var status = backgroundJobException.Code switch
|
||||
{
|
||||
"background_job_not_found" => StatusCodes.Status404NotFound,
|
||||
"background_job_not_cancellable" or "background_job_not_retryable" => StatusCodes.Status409Conflict,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
backgroundJobException.Message,
|
||||
status,
|
||||
backgroundJobException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantContextConflictException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -417,6 +448,8 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
var status = exception.Code switch
|
||||
{
|
||||
"tenant_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"auth_session_not_found" => StatusCodes.Status404NotFound,
|
||||
"current_auth_session_cannot_be_revoked" => StatusCodes.Status409Conflict,
|
||||
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
|
||||
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||
"auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable,
|
||||
|
||||
@@ -71,12 +71,6 @@
|
||||
"Redis": {
|
||||
"KeyPrefix": "tiku"
|
||||
},
|
||||
"BackgroundProcessing": {
|
||||
"Enabled": true,
|
||||
"JobPollSeconds": 2,
|
||||
"JobParallelism": 4,
|
||||
"JobBatchSize": 5
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"PollSeconds": 60,
|
||||
@@ -113,6 +107,7 @@
|
||||
"AllowedMimeTypes": [
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/gzip",
|
||||
"text/csv",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
@@ -145,6 +140,13 @@
|
||||
"KeyId": "",
|
||||
"MasterKey": ""
|
||||
},
|
||||
"ClamAV": {
|
||||
"Host": "localhost",
|
||||
"Port": 3310,
|
||||
"TimeoutSeconds": 30,
|
||||
"ChunkBytes": 65536,
|
||||
"StreamMaxLength": 524288000
|
||||
},
|
||||
"DataProtection": {
|
||||
"ApplicationName": "Tiku.Api",
|
||||
"CertificatePath": "",
|
||||
|
||||
30
Tiku.Application/Assets/IAssetSecurityScanner.cs
Normal file
30
Tiku.Application/Assets/IAssetSecurityScanner.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Tiku.Application.Assets;
|
||||
|
||||
public enum AssetSecurityScanVerdict
|
||||
{
|
||||
Clean,
|
||||
Infected
|
||||
}
|
||||
|
||||
public sealed record AssetSecurityScanResult(
|
||||
AssetSecurityScanVerdict Verdict,
|
||||
string Provider,
|
||||
string? Signature,
|
||||
long BytesScanned,
|
||||
string RawResponse);
|
||||
|
||||
public interface IAssetSecurityScanner
|
||||
{
|
||||
Task<AssetSecurityScanResult> ScanAsync(
|
||||
Stream content,
|
||||
long? declaredLength,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> CheckHealthAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class AssetSecurityScannerException(string code, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -99,6 +99,41 @@ public sealed record PasswordChangeChallengeRequest(
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record PasswordResetCodeRequest(
|
||||
Guid TenantId,
|
||||
string Phone,
|
||||
string? IpAddress,
|
||||
string? UserAgent,
|
||||
string? DeviceId = null);
|
||||
|
||||
public sealed record PasswordResetRequest(
|
||||
Guid TenantId,
|
||||
string Phone,
|
||||
string Code,
|
||||
string NewPassword,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record AuthenticatedPasswordChangeRequest(
|
||||
Guid UserId,
|
||||
Guid SessionId,
|
||||
string CurrentPassword,
|
||||
string NewPassword,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record AuthSessionSummary(
|
||||
Guid SessionFamilyId,
|
||||
AuthRealm Realm,
|
||||
Guid? TenantId,
|
||||
string Provider,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastRotatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string? IpAddressMasked,
|
||||
string? UserAgent,
|
||||
bool IsCurrent);
|
||||
|
||||
public sealed record SmsSendResult(
|
||||
Guid VerificationId,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
@@ -25,3 +25,9 @@ public sealed class AuthSecurityUnavailableException()
|
||||
|
||||
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
|
||||
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");
|
||||
|
||||
public sealed class AuthSessionNotFoundException()
|
||||
: AuthException("auth_session_not_found", "The requested authentication session was not found.");
|
||||
|
||||
public sealed class CurrentAuthSessionCannotBeRevokedException()
|
||||
: AuthException("current_auth_session_cannot_be_revoked", "Use logout to revoke the current authentication session.");
|
||||
|
||||
15
Tiku.Application/Auth/IAuthAdministrationService.cs
Normal file
15
Tiku.Application/Auth/IAuthAdministrationService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public sealed record AdministrativePasswordResetRequest(
|
||||
Guid ActorUserId,
|
||||
Guid TargetUserId,
|
||||
Guid? TenantId,
|
||||
string TemporaryPassword,
|
||||
string Reason);
|
||||
|
||||
public interface IAuthAdministrationService
|
||||
{
|
||||
Task ResetPasswordAsync(
|
||||
AdministrativePasswordResetRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -31,4 +31,16 @@ public interface IAuthService
|
||||
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
|
||||
PasswordChangeChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SmsSendResult> RequestPasswordResetAsync(
|
||||
PasswordResetCodeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task ResetPasswordAsync(
|
||||
PasswordResetRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticationResult> ChangePasswordAsync(
|
||||
AuthenticatedPasswordChangeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,25 @@ public interface IAuthSessionStore
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default);
|
||||
Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default);
|
||||
Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<AuthSessionSummary>> ListActiveAsync(
|
||||
Guid userId,
|
||||
Guid currentSessionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeOwnedFamilyAsync(
|
||||
Guid userId,
|
||||
Guid currentSessionId,
|
||||
Guid sessionFamilyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSessionIssueRequest(
|
||||
|
||||
@@ -3,23 +3,34 @@ using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Application.Jobs;
|
||||
|
||||
public sealed class BackgroundJobException(string code, string message) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
|
||||
public sealed record CreateBackgroundJobCommand(
|
||||
Guid TenantId,
|
||||
string JobType,
|
||||
JsonElement Payload,
|
||||
DateTimeOffset? RunAfter = null,
|
||||
int MaxRetries = 3);
|
||||
int MaxRetries = 3,
|
||||
string? IdempotencyKey = null,
|
||||
bool IsSystemJob = false);
|
||||
|
||||
public sealed record BackgroundJobItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string JobType,
|
||||
string? IdempotencyKey,
|
||||
BackgroundJobStatus Status,
|
||||
int RetryCount,
|
||||
int MaxRetries,
|
||||
DateTimeOffset? RunAfter,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
DateTimeOffset? CancellationRequestedAt,
|
||||
Guid? CancellationRequestedBy,
|
||||
string? CancellationReason,
|
||||
string? LastError,
|
||||
Guid? OutputAssetId,
|
||||
JsonElement Result);
|
||||
@@ -48,4 +59,29 @@ public interface IBackgroundJobService
|
||||
string? jobType = null,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackgroundJobItem?> GetAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<BackgroundJobItem>> ListPlatformAsync(
|
||||
Guid? tenantId = null,
|
||||
string? jobType = null,
|
||||
BackgroundJobStatus? status = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackgroundJobItem> RequestCancellationAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
Guid actorUserId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackgroundJobItem> RetryAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
Guid actorUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ public static class BackendPermissions
|
||||
public const string PlatformSmsWrite = "platform:sms:write";
|
||||
public const string PlatformPaymentRead = "platform:payment:read";
|
||||
public const string PlatformPaymentWrite = "platform:payment:write";
|
||||
public const string PlatformOperationsView = "platform:operations:view";
|
||||
public const string PlatformOperationsManage = "platform:operations:manage";
|
||||
|
||||
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -73,7 +75,9 @@ public static class BackendPermissions
|
||||
PlatformSmsRead,
|
||||
PlatformSmsWrite,
|
||||
PlatformPaymentRead,
|
||||
PlatformPaymentWrite
|
||||
PlatformPaymentWrite,
|
||||
PlatformOperationsView,
|
||||
PlatformOperationsManage
|
||||
};
|
||||
|
||||
public static void EnsureTenant(string permissionCode)
|
||||
|
||||
@@ -83,6 +83,7 @@ public static class PermissionModuleCatalog
|
||||
["platform_crm"] = null,
|
||||
["platform_sms"] = null,
|
||||
["platform_payment"] = null,
|
||||
["platform_operations"] = null,
|
||||
["commerce"] = SaasFeatureCatalog.StudentStore
|
||||
};
|
||||
|
||||
@@ -114,6 +115,7 @@ public static class PermissionModuleCatalog
|
||||
BackendPermissions.PlatformCrmRead or BackendPermissions.PlatformCrmWrite => "platform_crm",
|
||||
BackendPermissions.PlatformSmsRead or BackendPermissions.PlatformSmsWrite => "platform_sms",
|
||||
BackendPermissions.PlatformPaymentRead or BackendPermissions.PlatformPaymentWrite => "platform_payment",
|
||||
BackendPermissions.PlatformOperationsView or BackendPermissions.PlatformOperationsManage => "platform_operations",
|
||||
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
|
||||
};
|
||||
|
||||
@@ -26,4 +26,9 @@ public interface IObjectStorageService
|
||||
Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||
ObjectStorageHeadRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Stream> OpenReadAsync(
|
||||
ObjectStorageReadRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<Stream>(new NotSupportedException("Object storage read streaming is not configured."));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ public sealed record ObjectStorageHeadRequest(
|
||||
long? DeclaredFileSizeBytes = null,
|
||||
string? DeclaredChecksumSha256 = null);
|
||||
|
||||
public sealed record ObjectStorageReadRequest(
|
||||
Guid TenantId,
|
||||
string Provider,
|
||||
string Bucket,
|
||||
string ObjectKey);
|
||||
|
||||
public sealed record ObjectStorageWriteRequest(
|
||||
Guid TenantId,
|
||||
string Provider,
|
||||
|
||||
39
Tiku.Application/Tenancy/TenantLifecycleModels.cs
Normal file
39
Tiku.Application/Tenancy/TenantLifecycleModels.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Application.Tenancy;
|
||||
|
||||
public sealed record TenantArchivePreview(
|
||||
Guid TenantId,
|
||||
bool CanArchive,
|
||||
bool HasRecentSuccessfulExport,
|
||||
IReadOnlyCollection<string> Blockers);
|
||||
|
||||
public sealed record TenantLifecycleOperationItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
TenantLifecycleOperationType OperationType,
|
||||
TenantLifecycleOperationStatus Status,
|
||||
Guid RequestedBy,
|
||||
Guid? TargetUserId,
|
||||
Guid? ExportAssetId,
|
||||
string? Reason,
|
||||
string? LastError,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? CompletedAt);
|
||||
|
||||
public interface ITenantLifecycleService
|
||||
{
|
||||
Task<TenantArchivePreview> PreviewArchiveAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
||||
Task<TenantLifecycleOperationItem> CreateExportAsync(Guid tenantId, Guid actorUserId, CancellationToken cancellationToken = default);
|
||||
Task<TenantLifecycleOperationItem?> GetOperationAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default);
|
||||
Task<ObjectStorageSignedUrl> SignExportDownloadAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default);
|
||||
Task<TenantLifecycleOperationItem> ArchiveAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default);
|
||||
Task<TenantLifecycleOperationItem> RestoreAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default);
|
||||
Task<TenantLifecycleOperationItem> TransferOwnerAsync(Guid tenantId, Guid actorUserId, Guid targetUserId, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class TenantLifecycleException(string code, string message) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -142,6 +142,7 @@ public sealed class PlatformBackendUserRole : Entity
|
||||
public sealed class BackgroundJob : AuditableTenantEntity
|
||||
{
|
||||
public string JobType { get; set; } = string.Empty;
|
||||
public string? IdempotencyKey { get; set; }
|
||||
public BackgroundJobStatus Status { get; set; } = BackgroundJobStatus.Pending;
|
||||
public JsonElement Payload { get; set; } = JsonDefaults.Object();
|
||||
public int RetryCount { get; set; }
|
||||
@@ -151,11 +152,41 @@ public sealed class BackgroundJob : AuditableTenantEntity
|
||||
public DateTimeOffset? RunAfter { get; set; }
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public DateTimeOffset? CancellationRequestedAt { get; set; }
|
||||
public Guid? CancellationRequestedBy { get; set; }
|
||||
public string? CancellationReason { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public Guid? OutputAssetId { get; set; }
|
||||
public JsonElement Result { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class TenantLifecycleOperation : AuditableTenantEntity
|
||||
{
|
||||
public TenantLifecycleOperationType OperationType { get; set; }
|
||||
public TenantLifecycleOperationStatus Status { get; set; } = TenantLifecycleOperationStatus.Pending;
|
||||
public Guid RequestedBy { get; set; }
|
||||
public Guid? TargetUserId { get; set; }
|
||||
public Guid? ExportAssetId { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public JsonElement Result { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WorkerHeartbeat : Entity
|
||||
{
|
||||
public string WorkerId { get; set; } = string.Empty;
|
||||
public string Processor { get; set; } = string.Empty;
|
||||
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset LastHeartbeatAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastIterationStartedAt { get; set; }
|
||||
public DateTimeOffset? LastIterationCompletedAt { get; set; }
|
||||
public DateTimeOffset? LastSucceededAt { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public bool IsRunning { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserNotification : AuditableTenantEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
@@ -255,3 +286,7 @@ public enum TenantThemeConfigStatus { Draft, Published }
|
||||
public enum BackendPermissionArea { Platform, Tenant, Both }
|
||||
public enum BackendRoleStatus { Active, Disabled, Archived }
|
||||
public enum BackgroundJobStatus { Pending, Processing, Succeeded, Failed, Cancelled }
|
||||
|
||||
public enum TenantLifecycleOperationType { Export, Archive, Restore, OwnerTransfer }
|
||||
|
||||
public enum TenantLifecycleOperationStatus { Pending, Processing, Succeeded, Failed }
|
||||
|
||||
@@ -191,7 +191,7 @@ public sealed class AssetAccessService(
|
||||
throw new AssetAccessException("Asset upload has not been verified.", "ASSET_UPLOAD_NOT_VERIFIED");
|
||||
}
|
||||
|
||||
if (asset.SecurityScanStatus is AssetSecurityScanStatus.Failed or AssetSecurityScanStatus.Scanning)
|
||||
if (asset.SecurityScanStatus is not (AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired))
|
||||
{
|
||||
throw new AssetAccessException("Asset security scan has not passed.", "ASSET_SECURITY_SCAN_NOT_PASSED");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
@@ -17,7 +18,8 @@ public sealed class AssetManagementService(
|
||||
TikuDbContext dbContext,
|
||||
IObjectStorageService objectStorageService,
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
IFeatureAccessService featureAccessService) : IAssetManagementService
|
||||
IFeatureAccessService featureAccessService,
|
||||
IBackgroundJobService backgroundJobService) : IAssetManagementService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
@@ -286,6 +288,16 @@ public sealed class AssetManagementService(
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
|
||||
await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
actor.TenantId,
|
||||
"asset_security_scan",
|
||||
JsonSerializer.SerializeToElement(new { assetId = asset.Id }),
|
||||
MaxRetries: 5,
|
||||
IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}",
|
||||
IsSystemJob: true),
|
||||
cancellationToken);
|
||||
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
|
||||
142
Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs
Normal file
142
Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed class ClamAvOptions
|
||||
{
|
||||
public const string SectionName = "Security:ClamAV";
|
||||
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 3310;
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
public int ChunkBytes { get; set; } = 64 * 1024;
|
||||
public long StreamMaxLength { get; set; } = 500L * 1024 * 1024;
|
||||
|
||||
public static bool BeValid(ClamAvOptions options) =>
|
||||
!string.IsNullOrWhiteSpace(options.Host) &&
|
||||
options.Port is > 0 and <= 65535 &&
|
||||
options.TimeoutSeconds is >= 1 and <= 600 &&
|
||||
options.ChunkBytes is >= 1024 and <= 1024 * 1024 &&
|
||||
options.StreamMaxLength > 0;
|
||||
}
|
||||
|
||||
public sealed class ClamAvAssetSecurityScanner(IOptions<ClamAvOptions> options) : IAssetSecurityScanner
|
||||
{
|
||||
private readonly ClamAvOptions settings = options.Value;
|
||||
|
||||
public async Task<AssetSecurityScanResult> ScanAsync(
|
||||
Stream content,
|
||||
long? declaredLength,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedTimestamp = Stopwatch.GetTimestamp();
|
||||
if (declaredLength > settings.StreamMaxLength)
|
||||
{
|
||||
throw new AssetSecurityScannerException(
|
||||
"clamav_stream_too_large",
|
||||
"Asset exceeds the configured ClamAV StreamMaxLength.");
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(settings.Host, settings.Port, timeout.Token);
|
||||
await using var network = client.GetStream();
|
||||
await network.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token);
|
||||
var buffer = new byte[settings.ChunkBytes];
|
||||
var lengthBuffer = new byte[4];
|
||||
long total = 0;
|
||||
while (true)
|
||||
{
|
||||
var read = await content.ReadAsync(buffer, timeout.Token);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
if (total > settings.StreamMaxLength)
|
||||
{
|
||||
throw new AssetSecurityScannerException(
|
||||
"clamav_stream_too_large",
|
||||
"Asset exceeds the configured ClamAV StreamMaxLength.");
|
||||
}
|
||||
BinaryPrimitives.WriteUInt32BigEndian(lengthBuffer, (uint)read);
|
||||
await network.WriteAsync(lengthBuffer, timeout.Token);
|
||||
await network.WriteAsync(buffer.AsMemory(0, read), timeout.Token);
|
||||
}
|
||||
Array.Clear(lengthBuffer);
|
||||
await network.WriteAsync(lengthBuffer, timeout.Token);
|
||||
await network.FlushAsync(timeout.Token);
|
||||
var response = await ReadResponseAsync(network, timeout.Token);
|
||||
if (response.EndsWith(": OK", StringComparison.Ordinal))
|
||||
{
|
||||
WorkerTelemetry.RecordScan("clean", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
return new AssetSecurityScanResult(AssetSecurityScanVerdict.Clean, "clamav", null, total, response);
|
||||
}
|
||||
if (response.EndsWith(" FOUND", StringComparison.Ordinal))
|
||||
{
|
||||
var separator = response.IndexOf(": ", StringComparison.Ordinal);
|
||||
var signature = separator >= 0
|
||||
? response[(separator + 2)..^" FOUND".Length]
|
||||
: "unknown";
|
||||
WorkerTelemetry.RecordScan("infected", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
return new AssetSecurityScanResult(AssetSecurityScanVerdict.Infected, "clamav", signature, total, response);
|
||||
}
|
||||
throw new AssetSecurityScannerException("clamav_scan_error", $"ClamAV returned an error response: {response}");
|
||||
}
|
||||
catch (AssetSecurityScannerException)
|
||||
{
|
||||
WorkerTelemetry.RecordScan("error", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WorkerTelemetry.RecordScan("timeout", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
throw new AssetSecurityScannerException("clamav_timeout", "ClamAV scan timed out.", exception);
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or IOException)
|
||||
{
|
||||
WorkerTelemetry.RecordScan("unavailable", Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
throw new AssetSecurityScannerException("clamav_unavailable", "ClamAV is unavailable.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CheckHealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(settings.Host, settings.Port, timeout.Token);
|
||||
await using var network = client.GetStream();
|
||||
await network.WriteAsync("zPING\0"u8.ToArray(), timeout.Token);
|
||||
await network.FlushAsync(timeout.Token);
|
||||
return string.Equals(await ReadResponseAsync(network, timeout.Token), "PONG", StringComparison.Ordinal);
|
||||
}
|
||||
catch when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadResponseAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var single = new byte[1];
|
||||
while (await stream.ReadAsync(single, cancellationToken) == 1 && single[0] != 0)
|
||||
{
|
||||
buffer.WriteByte(single[0]);
|
||||
if (buffer.Length > 4096)
|
||||
{
|
||||
throw new AssetSecurityScannerException("clamav_response_too_large", "ClamAV response exceeded the safety limit.");
|
||||
}
|
||||
}
|
||||
return Encoding.UTF8.GetString(buffer.ToArray()).Trim();
|
||||
}
|
||||
}
|
||||
70
Tiku.Infrastructure/Auth/AuthAdministrationService.cs
Normal file
70
Tiku.Infrastructure/Auth/AuthAdministrationService.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
internal sealed class AuthAdministrationService(
|
||||
TikuDbContext dbContext,
|
||||
UserManager<User> userManager,
|
||||
IAuthSessionStore sessionStore) : IAuthAdministrationService
|
||||
{
|
||||
public async Task ResetPasswordAsync(
|
||||
AdministrativePasswordResetRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
throw new InvalidCredentialsException("password_reset_reason_required");
|
||||
}
|
||||
|
||||
var permitted = request.TenantId is { } tenantId
|
||||
? await dbContext.TenantMemberships.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.UserId == request.TargetUserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken)
|
||||
: await dbContext.PlatformBackendUserRoles.AnyAsync(
|
||||
item => item.UserId == request.TargetUserId,
|
||||
cancellationToken);
|
||||
if (!permitted)
|
||||
{
|
||||
throw new AuthSessionNotFoundException();
|
||||
}
|
||||
|
||||
var user = await userManager.FindByIdAsync(request.TargetUserId.ToString())
|
||||
?? throw new AuthSessionNotFoundException();
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, token, request.TemporaryPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = true;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to require a password change after the administrative reset.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "administrative_password_reset", cancellationToken);
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
ActorUserId = request.ActorUserId,
|
||||
Action = "auth.password.reset_by_administrator",
|
||||
TargetType = "user",
|
||||
TargetId = request.TargetUserId.ToString(),
|
||||
Details = System.Text.Json.JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
request.Reason,
|
||||
ForcePasswordChange = true
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,127 @@ public sealed class AuthService(
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SmsSendResult> RequestPasswordResetAsync(
|
||||
PasswordResetCodeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var userId = await dbContext.Users.AsNoTracking()
|
||||
.Where(user => user.Phone == phone && user.Status == UserStatus.Active)
|
||||
.Select(user => (Guid?)user.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
var eligible = userId.HasValue && await dbContext.TenantMemberships.AsNoTracking().AnyAsync(
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!eligible)
|
||||
{
|
||||
return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
}
|
||||
|
||||
return await smsVerificationService.CreateCodeAsync(
|
||||
new SendSmsCodeRequest(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.ResetPassword,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(
|
||||
PasswordResetRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(
|
||||
item => item.Phone == phone && item.Status == UserStatus.Active,
|
||||
cancellationToken);
|
||||
if (user is null || !await dbContext.TenantMemberships.AnyAsync(
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == user.Id &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.ResetPassword,
|
||||
request.Code,
|
||||
cancellationToken);
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, token, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password reset.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_reset", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id,
|
||||
request.TenantId,
|
||||
"auth.password.reset",
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ChangePasswordAsync(
|
||||
AuthenticatedPasswordChangeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await sessionStore.ResolveActiveSessionAsync(
|
||||
request.SessionId,
|
||||
request.UserId,
|
||||
cancellationToken) ?? throw new SessionRevokedException();
|
||||
var user = await userManager.FindByIdAsync(request.UserId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
var changed = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
|
||||
if (!changed.Succeeded)
|
||||
{
|
||||
var currentPasswordInvalid = changed.Errors.Any(error =>
|
||||
string.Equals(error.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase));
|
||||
throw new InvalidCredentialsException(currentPasswordInvalid ? "invalid_credentials" : "invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password change.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id,
|
||||
session.TenantId,
|
||||
"auth.password.changed_authenticated",
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
session.Realm,
|
||||
session.TenantId,
|
||||
user,
|
||||
PasswordProvider,
|
||||
user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthChallenge> FindChallengeAsync(
|
||||
string token,
|
||||
AuthChallengePurpose purpose,
|
||||
|
||||
@@ -196,6 +196,28 @@ public sealed class AuthSessionStore(
|
||||
return new AuthSessionValidationResult(userId, realm, tenantId);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.Id == sessionId && item.UserId == userId)
|
||||
.Select(item => new { item.Realm, item.TenantId })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ValidateAccessSessionAsync(
|
||||
sessionId,
|
||||
userId,
|
||||
session.Realm,
|
||||
session.TenantId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParseRefreshToken(refreshToken, out var locator))
|
||||
@@ -262,6 +284,66 @@ public sealed class AuthSessionStore(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<AuthSessionSummary>> ListActiveAsync(
|
||||
Guid userId,
|
||||
Guid currentSessionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await dbContext.AuthSessions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessions = await dbContext.AuthSessions.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return sessions
|
||||
.GroupBy(item => item.TokenFamilyId)
|
||||
.Select(group => new { All = group.ToArray(), Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) })
|
||||
.Where(value => value.Active is not null)
|
||||
.Select(value => new AuthSessionSummary(
|
||||
value.Active!.TokenFamilyId,
|
||||
value.Active.Realm,
|
||||
value.Active.TenantId,
|
||||
value.Active.Provider,
|
||||
value.All.Min(item => item.CreatedAt),
|
||||
value.Active.CreatedAt,
|
||||
value.Active.ExpiresAt,
|
||||
MaskIpAddress(value.Active.IpAddress),
|
||||
value.Active.UserAgent,
|
||||
value.Active.Id == currentSessionId))
|
||||
.OrderByDescending(item => item.IsCurrent)
|
||||
.ThenByDescending(item => item.LastRotatedAt)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task RevokeOwnedFamilyAsync(
|
||||
Guid userId,
|
||||
Guid currentSessionId,
|
||||
Guid sessionFamilyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await dbContext.AuthSessions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
|
||||
?? throw new SessionRevokedException();
|
||||
if (current.TokenFamilyId == sessionFamilyId)
|
||||
{
|
||||
throw new CurrentAuthSessionCannotBeRevokedException();
|
||||
}
|
||||
|
||||
var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync(
|
||||
item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId &&
|
||||
item.Realm == current.Realm && item.TenantId == current.TenantId,
|
||||
cancellationToken);
|
||||
if (!owned)
|
||||
{
|
||||
throw new AuthSessionNotFoundException();
|
||||
}
|
||||
|
||||
await RevokeFamilyCoreAsync(sessionFamilyId, "user_revoked_device", DateTimeOffset.UtcNow, cancellationToken);
|
||||
}
|
||||
|
||||
private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new()
|
||||
{
|
||||
Id = sessionId,
|
||||
@@ -350,4 +432,22 @@ public sealed class AuthSessionStore(
|
||||
throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? MaskIpAddress(string? value)
|
||||
{
|
||||
if (!System.Net.IPAddress.TryParse(value, out var address))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
bytes[3] = 0;
|
||||
return $"{new System.Net.IPAddress(bytes)}/24";
|
||||
}
|
||||
|
||||
Array.Clear(bytes, 8, bytes.Length - 8);
|
||||
return $"{new System.Net.IPAddress(bytes)}/64";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
new("platform_crm", "CRM 接入代管", BackendPermissionArea.Platform, null, 260),
|
||||
new("platform_sms", "短信服务", BackendPermissionArea.Platform, null, 270),
|
||||
new("platform_payment", "支付设置", BackendPermissionArea.Platform, null, 280),
|
||||
new("platform_operations", "平台运维", BackendPermissionArea.Platform, null, 290),
|
||||
new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300)
|
||||
];
|
||||
|
||||
@@ -93,6 +94,8 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
|
||||
new(BackendPermissions.PlatformSmsWrite, "平台短信管理", BackendPermissionArea.Platform, "platform_sms"),
|
||||
new(BackendPermissions.PlatformPaymentRead, "平台支付查询", BackendPermissionArea.Platform, "platform_payment"),
|
||||
new(BackendPermissions.PlatformPaymentWrite, "平台支付管理", BackendPermissionArea.Platform, "platform_payment"),
|
||||
new(BackendPermissions.PlatformOperationsView, "平台运维查询", BackendPermissionArea.Platform, "platform_operations"),
|
||||
new(BackendPermissions.PlatformOperationsManage, "平台运维管理", BackendPermissionArea.Platform, "platform_operations"),
|
||||
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
|
||||
|
||||
@@ -109,6 +109,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
|
||||
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IAuthAdministrationService, AuthAdministrationService>();
|
||||
services.AddScoped<IIdentityProvider, SelfHostedIdentityProvider>();
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<ITaxonomyService, TaxonomyService>();
|
||||
@@ -125,6 +126,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||
services.AddScoped<IAssetManagementService, AssetManagementService>();
|
||||
services.AddScoped<IAssetSecurityScanner, ClamAvAssetSecurityScanner>();
|
||||
services.AddOptions<ClamAvOptions>();
|
||||
services.AddScoped<IVideoPlaybackService, VideoPlaybackService>();
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
@@ -144,6 +147,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ISaasSubscriptionLifecycleService, SaasSubscriptionLifecycleService>();
|
||||
services.AddOptions<SaasSubscriptionLifecycleOptions>();
|
||||
services.AddScoped<ITenantOnboardingService, TenantOnboardingService>();
|
||||
services.AddScoped<ITenantLifecycleService, TenantLifecycleService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ITenantFeatureSnapshotProvider, TenantFeatureSnapshotProvider>();
|
||||
services.AddSingleton<TenantFeatureCacheInvalidator>();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
@@ -10,6 +13,8 @@ using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
@@ -25,7 +30,20 @@ internal sealed class BackgroundJobService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedJobType = NormalizeJobType(command.JobType);
|
||||
if (!(await featureAccessService.EvaluateAsync(
|
||||
var idempotencyKey = NormalizeIdempotencyKey(command.IdempotencyKey);
|
||||
if (idempotencyKey is not null)
|
||||
{
|
||||
var existing = await dbContext.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.JobType == normalizedJobType &&
|
||||
item.IdempotencyKey == idempotencyKey,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return ToItem(existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (!command.IsSystemJob && !(await featureAccessService.EvaluateAsync(
|
||||
command.TenantId,
|
||||
ResolveRequiredFeature(normalizedJobType, command.Payload),
|
||||
FeatureAccessOperation.Write,
|
||||
@@ -33,7 +51,7 @@ internal sealed class BackgroundJobService(
|
||||
{
|
||||
throw new InvalidOperationException("Tenant feature entitlement does not allow this background job.");
|
||||
}
|
||||
var quotaMetric = ResolveQuotaMetric(normalizedJobType);
|
||||
var quotaMetric = command.IsSystemJob ? null : ResolveQuotaMetric(normalizedJobType);
|
||||
var quotaConsumed = false;
|
||||
if (quotaMetric is not null)
|
||||
{
|
||||
@@ -53,6 +71,7 @@ internal sealed class BackgroundJobService(
|
||||
{
|
||||
TenantId = command.TenantId,
|
||||
JobType = normalizedJobType,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
Payload = command.Payload,
|
||||
RunAfter = command.RunAfter,
|
||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||
@@ -62,6 +81,23 @@ internal sealed class BackgroundJobService(
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException) when (idempotencyKey is not null)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var existing = await dbContext.BackgroundJobs.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.JobType == normalizedJobType &&
|
||||
item.IdempotencyKey == idempotencyKey,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (quotaConsumed && quotaMetric is not null)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None);
|
||||
}
|
||||
return ToItem(existing);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (quotaConsumed && quotaMetric is not null)
|
||||
@@ -167,12 +203,26 @@ internal sealed class BackgroundJobService(
|
||||
bool alreadyClaimed,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startedTimestamp = Stopwatch.GetTimestamp();
|
||||
if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) ||
|
||||
(alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!(await featureAccessService.EvaluateAsync(
|
||||
await dbContext.Entry(job).ReloadAsync(cancellationToken);
|
||||
if (job.CancellationRequestedAt.HasValue)
|
||||
{
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
await CompleteAsync(
|
||||
job,
|
||||
workerId,
|
||||
BackgroundJobStatus.Cancelled,
|
||||
job.Result,
|
||||
null,
|
||||
cancellationToken);
|
||||
return true;
|
||||
}
|
||||
if (job.JobType is not ("asset_security_scan" or "tenant_export") && !(await featureAccessService.EvaluateAsync(
|
||||
job.TenantId,
|
||||
ResolveRequiredFeature(job.JobType, job.Payload),
|
||||
FeatureAccessOperation.Write,
|
||||
@@ -206,7 +256,11 @@ internal sealed class BackgroundJobService(
|
||||
$"Background job {job.JobType}", job.Id.ToString("N")),
|
||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||
cancellationToken);
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
var cancellationRequested = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.Where(item => item.Id == job.Id)
|
||||
.Select(item => item.CancellationRequestedAt != null)
|
||||
.SingleAsync(cancellationToken);
|
||||
job.Status = cancellationRequested ? BackgroundJobStatus.Cancelled : BackgroundJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = null;
|
||||
job.Result = result;
|
||||
@@ -214,7 +268,13 @@ internal sealed class BackgroundJobService(
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
job.RetryCount++;
|
||||
job.LastError = exception.Message;
|
||||
job.LastError = exception is AssetSecurityScannerException scannerException
|
||||
? $"{scannerException.Code}: {scannerException.Message}"
|
||||
: exception.Message;
|
||||
if (exception is AssetSecurityScannerException assetScanException)
|
||||
{
|
||||
await RecordAssetScanRetryAsync(job, assetScanException, cancellationToken);
|
||||
}
|
||||
job.Status = job.RetryCount > job.MaxRetries
|
||||
? BackgroundJobStatus.Failed
|
||||
: BackgroundJobStatus.Pending;
|
||||
@@ -225,6 +285,7 @@ internal sealed class BackgroundJobService(
|
||||
finally
|
||||
{
|
||||
await CompleteAsync(job, workerId, job.Status, job.Result, job.LastError, cancellationToken);
|
||||
WorkerTelemetry.RecordJob(job.JobType, job.Status.ToString(), Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -274,6 +335,126 @@ internal sealed class BackgroundJobService(
|
||||
return jobs.Select(ToItem).ToArray();
|
||||
}
|
||||
|
||||
public async Task<BackgroundJobItem?> GetAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.BackgroundJobs.AsNoTracking().Where(item => item.Id == jobId);
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.TenantId == tenantId.Value);
|
||||
}
|
||||
var job = await query.SingleOrDefaultAsync(cancellationToken);
|
||||
return job is null ? null : ToItem(job);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<BackgroundJobItem>> ListPlatformAsync(
|
||||
Guid? tenantId = null,
|
||||
string? jobType = null,
|
||||
BackgroundJobStatus? status = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.BackgroundJobs.AsNoTracking().AsQueryable();
|
||||
if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(jobType))
|
||||
{
|
||||
var normalized = NormalizeJobType(jobType);
|
||||
query = query.Where(item => item.JobType == normalized);
|
||||
}
|
||||
if (status.HasValue) query = query.Where(item => item.Status == status.Value);
|
||||
return (await query.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(limit, 1, 500))
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.Select(ToItem)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<BackgroundJobItem> RequestCancellationAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
Guid actorUserId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new BackgroundJobException("background_job_cancel_reason_required", "Cancellation reason is required.");
|
||||
}
|
||||
var job = await FindMutableAsync(jobId, tenantId, cancellationToken);
|
||||
if (job.Status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled)
|
||||
{
|
||||
throw new BackgroundJobException("background_job_not_cancellable", "Only pending or processing jobs can be cancelled.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
job.CancellationRequestedAt = now;
|
||||
job.CancellationRequestedBy = actorUserId;
|
||||
job.CancellationReason = reason.Trim();
|
||||
if (job.Status == BackgroundJobStatus.Pending)
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Cancelled;
|
||||
job.CompletedAt = now;
|
||||
}
|
||||
AddMutationAudit(job, actorUserId, "background_job.cancel_requested");
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(job);
|
||||
}
|
||||
|
||||
public async Task<BackgroundJobItem> RetryAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
Guid actorUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var job = await FindMutableAsync(jobId, tenantId, cancellationToken);
|
||||
if (job.Status is not (BackgroundJobStatus.Failed or BackgroundJobStatus.Cancelled))
|
||||
{
|
||||
throw new BackgroundJobException("background_job_not_retryable", "Only failed or cancelled jobs can be retried.");
|
||||
}
|
||||
|
||||
job.Status = BackgroundJobStatus.Pending;
|
||||
job.RunAfter = DateTimeOffset.UtcNow;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.LockedBy = null;
|
||||
job.LockExpiresAt = null;
|
||||
job.LastError = null;
|
||||
job.CancellationRequestedAt = null;
|
||||
job.CancellationRequestedBy = null;
|
||||
job.CancellationReason = null;
|
||||
AddMutationAudit(job, actorUserId, "background_job.retry_requested");
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(job);
|
||||
}
|
||||
|
||||
private async Task<BackgroundJob> FindMutableAsync(
|
||||
Guid jobId,
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = dbContext.BackgroundJobs.Where(item => item.Id == jobId);
|
||||
if (tenantId.HasValue) query = query.Where(item => item.TenantId == tenantId.Value);
|
||||
return await query.SingleOrDefaultAsync(cancellationToken) ??
|
||||
throw new BackgroundJobException("background_job_not_found", "Background job was not found.");
|
||||
}
|
||||
|
||||
private void AddMutationAudit(BackgroundJob job, Guid actorUserId, string action)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
ActorUserId = actorUserId,
|
||||
Action = action,
|
||||
TargetType = "background_job",
|
||||
TargetId = job.Id.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(new { job.JobType, job.Status })
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessCoreAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
BackgroundJob job,
|
||||
@@ -285,7 +466,8 @@ internal sealed class BackgroundJobService(
|
||||
{
|
||||
"content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
|
||||
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
|
||||
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
|
||||
"asset_security_scan" => await ProcessAssetSecurityScanAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
"tenant_export" => await ProcessTenantExportAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
|
||||
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken),
|
||||
@@ -334,6 +516,308 @@ internal sealed class BackgroundJobService(
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ProcessAssetSecurityScanAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var assetId = GetJsonGuid(job.Payload, "assetId") ??
|
||||
throw new InvalidOperationException("asset_security_scan job requires assetId.");
|
||||
var asset = await scopedDbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == job.TenantId && item.Id == assetId,
|
||||
cancellationToken) ?? throw new InvalidOperationException("Asset security scan target was not found.");
|
||||
if (asset.UploadStatus != AssetUploadStatus.Verified ||
|
||||
string.IsNullOrWhiteSpace(asset.Bucket) ||
|
||||
string.IsNullOrWhiteSpace(asset.ObjectKey))
|
||||
{
|
||||
throw new InvalidOperationException("Asset must have a verified object location before security scanning.");
|
||||
}
|
||||
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Scanning;
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
var scanner = scopedProvider.GetRequiredService<IAssetSecurityScanner>();
|
||||
var storage = scopedProvider.GetRequiredService<Tiku.Application.Storage.IObjectStorageService>();
|
||||
await using var content = await storage.OpenReadAsync(
|
||||
new Tiku.Application.Storage.ObjectStorageReadRequest(
|
||||
job.TenantId,
|
||||
asset.StorageProvider switch
|
||||
{
|
||||
AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev,
|
||||
_ => throw new InvalidOperationException("Asset storage provider does not support security scanning.")
|
||||
},
|
||||
asset.Bucket,
|
||||
asset.ObjectKey),
|
||||
cancellationToken);
|
||||
var result = await scanner.ScanAsync(content, asset.VerifiedSizeBytes ?? asset.FileSizeBytes, cancellationToken);
|
||||
var infected = result.Verdict == AssetSecurityScanVerdict.Infected;
|
||||
asset.SecurityScanStatus = infected ? AssetSecurityScanStatus.Failed : AssetSecurityScanStatus.Passed;
|
||||
asset.SecurityScannedAt = DateTimeOffset.UtcNow;
|
||||
asset.SecurityScanProvider = result.Provider;
|
||||
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
verdict = result.Verdict.ToString(),
|
||||
result.Signature,
|
||||
result.BytesScanned
|
||||
});
|
||||
scopedDbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
AssetId = asset.Id,
|
||||
Provider = result.Provider,
|
||||
ScanStatus = asset.SecurityScanStatus,
|
||||
RiskLevel = infected ? AssetSecurityRiskLevel.Critical : AssetSecurityRiskLevel.None,
|
||||
IssueCodes = infected ? [result.Signature ?? "malware_detected"] : [],
|
||||
Details = asset.SecurityScanSummary
|
||||
});
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
assetId = asset.Id,
|
||||
status = asset.SecurityScanStatus.ToString(),
|
||||
result.Signature,
|
||||
result.BytesScanned
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RecordAssetScanRetryAsync(
|
||||
BackgroundJob job,
|
||||
AssetSecurityScannerException exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var assetId = GetJsonGuid(job.Payload, "assetId");
|
||||
if (assetId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == job.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.SecurityScanProvider = "clamav";
|
||||
asset.SecurityScanSummary = JsonSerializer.SerializeToElement(new { errorCode = exception.Code });
|
||||
dbContext.ContentAssetSecurityScanEvents.Add(new ContentAssetSecurityScanEvent
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
AssetId = asset.Id,
|
||||
Provider = "clamav",
|
||||
ScanStatus = AssetSecurityScanStatus.Pending,
|
||||
RiskLevel = AssetSecurityRiskLevel.None,
|
||||
IssueCodes = [exception.Code],
|
||||
Details = asset.SecurityScanSummary
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ProcessTenantExportAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operationId = GetJsonGuid(job.Payload, "operationId") ??
|
||||
throw new InvalidOperationException("tenant_export job requires operationId.");
|
||||
var operation = await scopedDbContext.TenantLifecycleOperations.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == job.TenantId && item.Id == operationId &&
|
||||
item.OperationType == TenantLifecycleOperationType.Export,
|
||||
cancellationToken) ?? throw new InvalidOperationException("Tenant export operation was not found.");
|
||||
operation.Status = TenantLifecycleOperationStatus.Processing;
|
||||
operation.StartedAt ??= DateTimeOffset.UtcNow;
|
||||
operation.LastError = null;
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var temporaryPath = Path.Combine(Path.GetTempPath(), $"tiku-tenant-export-{operation.Id:N}.tar.gz");
|
||||
try
|
||||
{
|
||||
var tenant = await scopedDbContext.Tenants.AsNoTracking().SingleAsync(item => item.Id == job.TenantId, cancellationToken);
|
||||
var memberships = await scopedDbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.TenantId == job.TenantId)
|
||||
.Select(item => new { item.UserId, item.Role, item.Status, item.CreatedAt, item.UpdatedAt })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var domains = await scopedDbContext.TenantDomains.AsNoTracking()
|
||||
.Where(item => item.TenantId == job.TenantId)
|
||||
.Select(item => new { item.Id, item.Host, item.DomainType, item.Status, item.IsPrimary, item.CreatedAt, item.UpdatedAt })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var assets = await scopedDbContext.ContentAssets.AsNoTracking()
|
||||
.Where(item => item.TenantId == job.TenantId && item.Status == ContentStatus.Active)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var storage = scopedProvider.GetRequiredService<Tiku.Application.Storage.IObjectStorageService>();
|
||||
|
||||
await using (var file = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, FileOptions.Asynchronous))
|
||||
await using (var gzip = new GZipStream(file, CompressionLevel.Fastest, leaveOpen: false))
|
||||
await using (var archive = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: false))
|
||||
{
|
||||
await WriteJsonEntryAsync(archive, "manifest.json", new
|
||||
{
|
||||
format = "tiku-tenant-export",
|
||||
version = 1,
|
||||
tenantId = job.TenantId,
|
||||
operationId,
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
exclusions = new[] { "password_hashes", "auth_tokens", "refresh_tokens", "secret_plaintext", "data_protection_keys", "global_platform_data" },
|
||||
tables = new[] { "tenant", "tenant_memberships", "tenant_domains", "content_assets" }
|
||||
}, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant.jsonl", new[]
|
||||
{
|
||||
new { tenant.Id, tenant.Slug, tenant.Name, tenant.LegalName, tenant.Status, tenant.Mode, tenant.BillingStatus, tenant.OwnerUserId, tenant.CreatedAt, tenant.UpdatedAt }
|
||||
}, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant_memberships.jsonl", memberships, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/tenant_domains.jsonl", domains, cancellationToken);
|
||||
await WriteJsonLinesEntryAsync(archive, "data/content_assets.jsonl", assets.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.AssetKey,
|
||||
item.Title,
|
||||
item.FileName,
|
||||
item.StorageProvider,
|
||||
item.Bucket,
|
||||
item.ObjectKey,
|
||||
item.MimeType,
|
||||
item.FileSizeBytes,
|
||||
item.ChecksumSha256,
|
||||
item.UploadStatus,
|
||||
item.SecurityScanStatus,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt
|
||||
}), cancellationToken);
|
||||
|
||||
foreach (var asset in assets.Where(item =>
|
||||
item.UploadStatus == AssetUploadStatus.Verified &&
|
||||
item.SecurityScanStatus is AssetSecurityScanStatus.Passed or AssetSecurityScanStatus.NotRequired &&
|
||||
!string.IsNullOrWhiteSpace(item.Bucket) &&
|
||||
!string.IsNullOrWhiteSpace(item.ObjectKey)))
|
||||
{
|
||||
var provider = asset.StorageProvider switch
|
||||
{
|
||||
AssetStorageProvider.AliyunOss => Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => Tiku.Application.Storage.ObjectStorageProviders.LocalDev,
|
||||
_ => null
|
||||
};
|
||||
if (provider is null) continue;
|
||||
await using var content = await storage.OpenReadAsync(
|
||||
new Tiku.Application.Storage.ObjectStorageReadRequest(
|
||||
job.TenantId, provider, asset.Bucket!, asset.ObjectKey!),
|
||||
cancellationToken);
|
||||
var name = SanitizeTarPath(asset.FileName ?? asset.Id.ToString("N"));
|
||||
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, $"assets/{asset.Id:N}/{name}")
|
||||
{
|
||||
DataStream = content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await using var upload = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous);
|
||||
var providerName = storage.ConfiguredDefaultProvider();
|
||||
var bucket = storage.ConfiguredDefaultBucket();
|
||||
var objectKey = storage.ValidateObjectKey(job.TenantId, $"{job.TenantId:N}/tenant-exports/{operation.Id:N}.tar.gz");
|
||||
var written = await storage.WriteObjectAsync(
|
||||
new Tiku.Application.Storage.ObjectStorageWriteRequest(
|
||||
job.TenantId,
|
||||
providerName,
|
||||
bucket,
|
||||
objectKey,
|
||||
"application/gzip",
|
||||
upload,
|
||||
upload.Length,
|
||||
Upsert: false),
|
||||
cancellationToken);
|
||||
var exportAsset = new ContentAsset
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
Title = "Tenant export archive",
|
||||
FileName = $"tenant-export-{operation.Id:N}.tar.gz",
|
||||
AssetType = ContentAssetType.Document,
|
||||
StorageProvider = providerName switch
|
||||
{
|
||||
Tiku.Application.Storage.ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
|
||||
Tiku.Application.Storage.ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
|
||||
_ => AssetStorageProvider.ExternalUrl
|
||||
},
|
||||
Bucket = written.Bucket,
|
||||
ObjectKey = written.ObjectKey,
|
||||
MimeType = "application/gzip",
|
||||
FileSizeBytes = written.SizeBytes,
|
||||
ChecksumSha256 = written.ChecksumSha256,
|
||||
UploadStatus = AssetUploadStatus.Verified,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifiedSizeBytes = written.SizeBytes,
|
||||
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
|
||||
Source = "tenant_export"
|
||||
};
|
||||
scopedDbContext.ContentAssets.Add(exportAsset);
|
||||
operation.ExportAssetId = exportAsset.Id;
|
||||
operation.Status = TenantLifecycleOperationStatus.Succeeded;
|
||||
operation.CompletedAt = DateTimeOffset.UtcNow;
|
||||
operation.Result = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
exportAssetId = exportAsset.Id,
|
||||
written.SizeBytes,
|
||||
assetCount = assets.Length
|
||||
});
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
job.OutputAssetId = exportAsset.Id;
|
||||
return operation.Result;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
operation.Status = TenantLifecycleOperationStatus.Failed;
|
||||
operation.LastError = exception.Message;
|
||||
operation.CompletedAt = DateTimeOffset.UtcNow;
|
||||
await scopedDbContext.SaveChangesAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteJsonEntryAsync<T>(
|
||||
TarWriter archive,
|
||||
string name,
|
||||
T value,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
await JsonSerializer.SerializeAsync(stream, value, cancellationToken: cancellationToken);
|
||||
stream.Position = 0;
|
||||
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
|
||||
await stream.DisposeAsync();
|
||||
}
|
||||
|
||||
private static async Task WriteJsonLinesEntryAsync<T>(
|
||||
TarWriter archive,
|
||||
string name,
|
||||
IEnumerable<T> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
await using (var writer = new StreamWriter(stream, new System.Text.UTF8Encoding(false), leaveOpen: true))
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(value));
|
||||
}
|
||||
}
|
||||
stream.Position = 0;
|
||||
archive.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = stream });
|
||||
await stream.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string SanitizeTarPath(string value)
|
||||
{
|
||||
var name = Path.GetFileName(value).Replace('\\', '_').Replace('/', '_');
|
||||
return string.IsNullOrWhiteSpace(name) ? "asset.bin" : name;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessContentExportAsync(
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
@@ -504,6 +988,17 @@ internal sealed class BackgroundJobService(
|
||||
return jobType.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeIdempotencyKey(string? value)
|
||||
{
|
||||
var normalized = value?.Trim();
|
||||
if (string.IsNullOrEmpty(normalized)) return null;
|
||||
if (normalized.Length > 200)
|
||||
{
|
||||
throw new BackgroundJobException("background_job_idempotency_key_too_long", "Idempotency key cannot exceed 200 characters.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ResolveRequiredFeature(string jobType, JsonElement payload) => jobType switch
|
||||
{
|
||||
"content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType"))
|
||||
@@ -575,12 +1070,16 @@ internal sealed class BackgroundJobService(
|
||||
job.Id,
|
||||
job.TenantId,
|
||||
job.JobType,
|
||||
job.IdempotencyKey,
|
||||
job.Status,
|
||||
job.RetryCount,
|
||||
job.MaxRetries,
|
||||
job.RunAfter,
|
||||
job.StartedAt,
|
||||
job.CompletedAt,
|
||||
job.CancellationRequestedAt,
|
||||
job.CancellationRequestedBy,
|
||||
job.CancellationReason,
|
||||
job.LastError,
|
||||
job.OutputAssetId,
|
||||
job.Result);
|
||||
|
||||
37
Tiku.Infrastructure/Observability/WorkerTelemetry.cs
Normal file
37
Tiku.Infrastructure/Observability/WorkerTelemetry.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Infrastructure.Observability;
|
||||
|
||||
public static class WorkerTelemetry
|
||||
{
|
||||
public const string MeterName = "Tiku.Worker";
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
private static readonly Counter<long> JobCounter = Meter.CreateCounter<long>("tiku.worker.jobs");
|
||||
private static readonly Histogram<double> JobDuration = Meter.CreateHistogram<double>("tiku.worker.job.duration", "ms");
|
||||
private static readonly Counter<long> ScanCounter = Meter.CreateCounter<long>("tiku.asset.security_scans");
|
||||
private static readonly Histogram<double> ScanDuration = Meter.CreateHistogram<double>("tiku.asset.security_scan.duration", "ms");
|
||||
private static readonly Counter<long> IterationCounter = Meter.CreateCounter<long>("tiku.worker.iterations");
|
||||
private static readonly Histogram<double> IterationDuration = Meter.CreateHistogram<double>("tiku.worker.iteration.duration", "ms");
|
||||
|
||||
public static void RecordJob(string jobType, string status, double elapsedMilliseconds)
|
||||
{
|
||||
var tags = new TagList { { "job.type", jobType }, { "job.status", status } };
|
||||
JobCounter.Add(1, tags);
|
||||
JobDuration.Record(elapsedMilliseconds, tags);
|
||||
}
|
||||
|
||||
public static void RecordScan(string status, double elapsedMilliseconds)
|
||||
{
|
||||
var tags = new TagList { { "scan.status", status } };
|
||||
ScanCounter.Add(1, tags);
|
||||
ScanDuration.Record(elapsedMilliseconds, tags);
|
||||
}
|
||||
|
||||
public static void RecordIteration(string processor, bool succeeded, double elapsedMilliseconds)
|
||||
{
|
||||
var tags = new TagList { { "worker.processor", processor }, { "worker.succeeded", succeeded } };
|
||||
IterationCounter.Add(1, tags);
|
||||
IterationDuration.Record(elapsedMilliseconds, tags);
|
||||
}
|
||||
}
|
||||
@@ -271,13 +271,18 @@ internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration<Back
|
||||
builder.ConfigureTenantEntity("background_jobs");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.JobType).HasMaxLength(100);
|
||||
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Payload).IsJson("{}");
|
||||
builder.Property(entity => entity.LockedBy).HasMaxLength(200);
|
||||
builder.Property(entity => entity.LastError).HasMaxLength(4000);
|
||||
builder.Property(entity => entity.CancellationReason).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Result).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.RunAfter, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.JobType, entity.Status, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.JobType, entity.IdempotencyKey })
|
||||
.IsUnique()
|
||||
.HasFilter("idempotency_key is not null");
|
||||
builder.HasOne<ContentAsset>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.OutputAssetId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
@@ -285,6 +290,38 @@ internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration<Back
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantLifecycleOperationConfiguration : IEntityTypeConfiguration<TenantLifecycleOperation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantLifecycleOperation> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_lifecycle_operations");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.OperationType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Reason).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.LastError).HasMaxLength(4000);
|
||||
builder.Property(entity => entity.Result).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.OperationType, entity.Status, entity.CreatedAt });
|
||||
builder.HasOne<ContentAsset>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ExportAssetId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkerHeartbeatConfiguration : IEntityTypeConfiguration<WorkerHeartbeat>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkerHeartbeat> builder)
|
||||
{
|
||||
builder.ConfigureEntity("worker_heartbeats");
|
||||
builder.Property(entity => entity.WorkerId).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Processor).HasMaxLength(100);
|
||||
builder.Property(entity => entity.LastError).HasMaxLength(4000);
|
||||
builder.HasIndex(entity => new { entity.WorkerId, entity.Processor }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.Processor, entity.LastHeartbeatAt });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class UserNotificationConfiguration : IEntityTypeConfiguration<UserNotification>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserNotification> builder)
|
||||
|
||||
20044
Tiku.Infrastructure/Persistence/Migrations/20260801024011_P0SystemStrengthening.Designer.cs
generated
Normal file
20044
Tiku.Infrastructure/Persistence/Migrations/20260801024011_P0SystemStrengthening.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class P0SystemStrengthening : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "cancellation_reason",
|
||||
table: "background_jobs",
|
||||
type: "character varying(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "cancellation_requested_at",
|
||||
table: "background_jobs",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "cancellation_requested_by",
|
||||
table: "background_jobs",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "idempotency_key",
|
||||
table: "background_jobs",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_lifecycle_operations",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
operation_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
requested_by = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
target_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
export_asset_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
last_error = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
result = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_lifecycle_operations", x => x.id);
|
||||
table.UniqueConstraint("ak_tenant_lifecycle_operations_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_lifecycle_operations_content_assets_tenant_id_export~",
|
||||
columns: x => new { x.tenant_id, x.export_asset_id },
|
||||
principalTable: "content_assets",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_lifecycle_operations_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "worker_heartbeats",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
worker_id = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
processor = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
last_heartbeat_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
last_iteration_started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
last_iteration_completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
last_succeeded_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
last_error = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
|
||||
is_running = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_worker_heartbeats", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_background_jobs_tenant_id_job_type_idempotency_key",
|
||||
table: "background_jobs",
|
||||
columns: new[] { "tenant_id", "job_type", "idempotency_key" },
|
||||
unique: true,
|
||||
filter: "idempotency_key is not null");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_lifecycle_operations_tenant_id_export_asset_id",
|
||||
table: "tenant_lifecycle_operations",
|
||||
columns: new[] { "tenant_id", "export_asset_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_lifecycle_operations_tenant_id_operation_type_status~",
|
||||
table: "tenant_lifecycle_operations",
|
||||
columns: new[] { "tenant_id", "operation_type", "status", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_worker_heartbeats_processor_last_heartbeat_at",
|
||||
table: "worker_heartbeats",
|
||||
columns: new[] { "processor", "last_heartbeat_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_worker_heartbeats_worker_id_processor",
|
||||
table: "worker_heartbeats",
|
||||
columns: new[] { "worker_id", "processor" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_lifecycle_operations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "worker_heartbeats");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_background_jobs_tenant_id_job_type_idempotency_key",
|
||||
table: "background_jobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "cancellation_reason",
|
||||
table: "background_jobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "cancellation_requested_at",
|
||||
table: "background_jobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "cancellation_requested_by",
|
||||
table: "background_jobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "idempotency_key",
|
||||
table: "background_jobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10404,6 +10404,19 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("CancellationReason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("cancellation_reason");
|
||||
|
||||
b.Property<DateTimeOffset?>("CancellationRequestedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cancellation_requested_at");
|
||||
|
||||
b.Property<Guid?>("CancellationRequestedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("cancellation_requested_by");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
@@ -10414,6 +10427,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("JobType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -10491,6 +10509,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("TenantId", "OutputAssetId")
|
||||
.HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id");
|
||||
|
||||
b.HasIndex("TenantId", "JobType", "IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_background_jobs_tenant_id_job_type_idempotency_key")
|
||||
.HasFilter("idempotency_key is not null");
|
||||
|
||||
b.HasIndex("TenantId", "JobType", "Status", "CreatedAt")
|
||||
.HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at");
|
||||
|
||||
@@ -11261,6 +11284,93 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("tenant_content_notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.TenantLifecycleOperation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("ExportAssetId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("export_asset_id");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<string>("OperationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("operation_type");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<Guid>("RequestedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("requested_by");
|
||||
|
||||
b.Property<JsonElement>("Result")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("result")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid?>("TargetUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("target_user_id");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tenant_lifecycle_operations");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_tenant_lifecycle_operations_tenant_id_id");
|
||||
|
||||
b.HasIndex("TenantId", "ExportAssetId")
|
||||
.HasDatabaseName("ix_tenant_lifecycle_operations_tenant_id_export_asset_id");
|
||||
|
||||
b.HasIndex("TenantId", "OperationType", "Status", "CreatedAt")
|
||||
.HasDatabaseName("ix_tenant_lifecycle_operations_tenant_id_operation_type_status~");
|
||||
|
||||
b.ToTable("tenant_lifecycle_operations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.TenantThemeConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -11637,6 +11747,68 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("user_notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.WorkerHeartbeat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<bool>("IsRunning")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_running");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<DateTimeOffset>("LastHeartbeatAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_heartbeat_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastIterationCompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_iteration_completed_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastIterationStartedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_iteration_started_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSucceededAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_succeeded_at");
|
||||
|
||||
b.Property<string>("Processor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("processor");
|
||||
|
||||
b.Property<DateTimeOffset>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("WorkerId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("worker_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_worker_heartbeats");
|
||||
|
||||
b.HasIndex("Processor", "LastHeartbeatAt")
|
||||
.HasDatabaseName("ix_worker_heartbeats_processor_last_heartbeat_at");
|
||||
|
||||
b.HasIndex("WorkerId", "Processor")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_worker_heartbeats_worker_id_processor");
|
||||
|
||||
b.ToTable("worker_heartbeats", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Platform.PermissionModule", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -18883,6 +19055,23 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_tenant_content_notifications_question_banks_tenant_id_sourc~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.TenantLifecycleOperation", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tenant_lifecycle_operations_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Content.ContentAsset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ExportAssetId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_tenant_lifecycle_operations_content_assets_tenant_id_export~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Operations.TenantThemeConfig", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Operations.TenantThemeTemplate", null)
|
||||
|
||||
@@ -174,6 +174,8 @@ public sealed class TikuDbContext(
|
||||
public DbSet<PlatformBackendRoleMenu> PlatformBackendRoleMenus => Set<PlatformBackendRoleMenu>();
|
||||
public DbSet<PlatformBackendUserRole> PlatformBackendUserRoles => Set<PlatformBackendUserRole>();
|
||||
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
|
||||
public DbSet<TenantLifecycleOperation> TenantLifecycleOperations => Set<TenantLifecycleOperation>();
|
||||
public DbSet<WorkerHeartbeat> WorkerHeartbeats => Set<WorkerHeartbeat>();
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<Badge> Badges => Set<Badge>();
|
||||
public DbSet<UserBadge> UserBadges => Set<UserBadge>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using AlibabaCloud.OSS.V2.Credentials;
|
||||
@@ -386,6 +387,33 @@ public sealed partial class AliyunOssObjectStorageService(
|
||||
"aliyun-oss-put-object");
|
||||
}
|
||||
|
||||
public async Task<Stream> OpenReadAsync(
|
||||
ObjectStorageReadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var provider = NormalizeProvider(request.Provider);
|
||||
var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey);
|
||||
if (provider != ObjectStorageProviders.AliyunOss)
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
$"{provider} does not support managed server-side reads.",
|
||||
"READ_PROVIDER_NOT_SUPPORTED");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request.Bucket))
|
||||
{
|
||||
throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED");
|
||||
}
|
||||
|
||||
var result = await GetClient().GetObjectAsync(
|
||||
new GetObjectRequest { Bucket = request.Bucket, Key = objectKey },
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
cancellationToken);
|
||||
return result.Body ?? throw new ObjectStorageException(
|
||||
"Object storage returned an empty response stream.",
|
||||
"STORAGE_OBJECT_EMPTY_RESPONSE");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class ObjectStorageOptions
|
||||
[
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/gzip",
|
||||
"text/csv",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
|
||||
303
Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs
Normal file
303
Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
internal sealed class TenantLifecycleService(
|
||||
TikuDbContext dbContext,
|
||||
IBackgroundJobService backgroundJobService,
|
||||
IAuthSessionStore sessionStore,
|
||||
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
|
||||
ITenantPublicCacheInvalidator publicCacheInvalidator,
|
||||
ITenantFeatureCacheInvalidator featureCacheInvalidator,
|
||||
IObjectStorageService objectStorageService) : ITenantLifecycleService
|
||||
{
|
||||
private static readonly TimeSpan RecentExportWindow = TimeSpan.FromHours(24);
|
||||
|
||||
public async Task<TenantArchivePreview> PreviewArchiveAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken)
|
||||
?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found.");
|
||||
var blockers = new List<string>();
|
||||
if (tenant.Status == TenantStatus.Archived) blockers.Add("tenant_already_archived");
|
||||
if (await dbContext.BackgroundJobs.AnyAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.Status == BackgroundJobStatus.Processing,
|
||||
cancellationToken))
|
||||
{
|
||||
blockers.Add("processing_background_jobs");
|
||||
}
|
||||
var recentExport = await HasRecentExportAsync(tenantId, cancellationToken);
|
||||
if (!recentExport) blockers.Add("recent_successful_export_required");
|
||||
return new TenantArchivePreview(tenantId, blockers.Count == 0, recentExport, blockers);
|
||||
}
|
||||
|
||||
public async Task<TenantLifecycleOperationItem> CreateExportAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireTenantAsync(tenantId, cancellationToken);
|
||||
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Export, null);
|
||||
dbContext.TenantLifecycleOperations.Add(operation);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"tenant_export",
|
||||
JsonSerializer.SerializeToElement(new { operationId = operation.Id }),
|
||||
MaxRetries: 3,
|
||||
IdempotencyKey: $"tenant-export:{operation.Id:N}",
|
||||
IsSystemJob: true),
|
||||
cancellationToken);
|
||||
return ToItem(operation);
|
||||
}
|
||||
|
||||
public async Task<TenantLifecycleOperationItem?> GetOperationAsync(
|
||||
Guid tenantId,
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == operationId,
|
||||
cancellationToken);
|
||||
return operation is null ? null : ToItem(operation);
|
||||
}
|
||||
|
||||
public async Task<ObjectStorageSignedUrl> SignExportDownloadAsync(
|
||||
Guid tenantId,
|
||||
Guid operationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var operation = await dbContext.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == operationId &&
|
||||
item.OperationType == TenantLifecycleOperationType.Export &&
|
||||
item.Status == TenantLifecycleOperationStatus.Succeeded,
|
||||
cancellationToken) ?? throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export is not ready.");
|
||||
var asset = operation.ExportAssetId.HasValue
|
||||
? await dbContext.ContentAssets.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == operation.ExportAssetId.Value,
|
||||
cancellationToken)
|
||||
: null;
|
||||
if (asset is null || string.IsNullOrWhiteSpace(asset.ObjectKey))
|
||||
{
|
||||
throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export asset was not found.");
|
||||
}
|
||||
return await objectStorageService.SignDownloadAsync(
|
||||
new ObjectStorageDownloadSignRequest(
|
||||
tenantId,
|
||||
ToProvider(asset.StorageProvider),
|
||||
asset.Bucket,
|
||||
asset.ObjectKey,
|
||||
TimeSpan.FromMinutes(15),
|
||||
asset.CdnUrl,
|
||||
asset.FileName,
|
||||
"attachment"),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TenantLifecycleOperationItem> ArchiveAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var preview = await PreviewArchiveAsync(tenantId, cancellationToken);
|
||||
if (!preview.CanArchive)
|
||||
{
|
||||
throw new TenantLifecycleException("tenant_archive_blocked", string.Join(',', preview.Blockers));
|
||||
}
|
||||
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
|
||||
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Archive, reason);
|
||||
operation.Status = TenantLifecycleOperationStatus.Succeeded;
|
||||
operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow;
|
||||
tenant.Status = TenantStatus.Archived;
|
||||
await dbContext.TenantDomains.Where(item => item.TenantId == tenantId)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled), cancellationToken);
|
||||
dbContext.TenantLifecycleOperations.Add(operation);
|
||||
AddAudit(tenantId, actorUserId, "tenant.archived", tenantId, reason);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await RevokeTenantSessionsAsync(tenantId, cancellationToken);
|
||||
await InvalidateAsync(tenantId, cancellationToken);
|
||||
return ToItem(operation);
|
||||
}
|
||||
|
||||
public async Task<TenantLifecycleOperationItem> RestoreAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
|
||||
if (tenant.Status != TenantStatus.Archived)
|
||||
{
|
||||
throw new TenantLifecycleException("tenant_not_archived", "Only archived tenants can be restored.");
|
||||
}
|
||||
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Restore, reason);
|
||||
operation.Status = TenantLifecycleOperationStatus.Succeeded;
|
||||
operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow;
|
||||
tenant.Status = TenantStatus.Suspended;
|
||||
await dbContext.TenantDomains.Where(item => item.TenantId == tenantId)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending), cancellationToken);
|
||||
dbContext.TenantLifecycleOperations.Add(operation);
|
||||
AddAudit(tenantId, actorUserId, "tenant.restored_suspended", tenantId, reason);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateAsync(tenantId, cancellationToken);
|
||||
return ToItem(operation);
|
||||
}
|
||||
|
||||
public async Task<TenantLifecycleOperationItem> TransferOwnerAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
Guid targetUserId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var tenant = await RequireTenantAsync(tenantId, cancellationToken);
|
||||
var target = await dbContext.TenantMemberships.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId && item.UserId == targetUserId && item.Status == MembershipStatus.Active,
|
||||
cancellationToken) ?? throw new TenantLifecycleException(
|
||||
"tenant_owner_target_not_active_member",
|
||||
"New owner must be an existing active tenant member.");
|
||||
var previousOwnerId = tenant.OwnerUserId;
|
||||
if (previousOwnerId == targetUserId)
|
||||
{
|
||||
throw new TenantLifecycleException("tenant_owner_unchanged", "Target user is already the tenant owner.");
|
||||
}
|
||||
var previous = previousOwnerId.HasValue
|
||||
? await dbContext.TenantMemberships.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId && item.UserId == previousOwnerId.Value,
|
||||
cancellationToken)
|
||||
: null;
|
||||
if (previous is not null) previous.Role = TenantRole.TenantAdmin;
|
||||
target.Role = TenantRole.TenantOwner;
|
||||
tenant.OwnerUserId = targetUserId;
|
||||
|
||||
var ownerRoleId = await dbContext.TenantBackendRoles
|
||||
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner")
|
||||
.Select(item => (Guid?)item.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (ownerRoleId.HasValue)
|
||||
{
|
||||
if (previousOwnerId.HasValue)
|
||||
{
|
||||
await dbContext.TenantBackendUserRoles
|
||||
.Where(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value && item.RoleId == ownerRoleId.Value)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
if (!await dbContext.TenantBackendUserRoles.AnyAsync(item =>
|
||||
item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value,
|
||||
cancellationToken))
|
||||
{
|
||||
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = targetUserId,
|
||||
RoleId = ownerRoleId.Value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.OwnerTransfer, reason);
|
||||
operation.TargetUserId = targetUserId;
|
||||
operation.Status = TenantLifecycleOperationStatus.Succeeded;
|
||||
operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow;
|
||||
operation.Result = JsonSerializer.SerializeToElement(new { previousOwnerId, newOwnerId = targetUserId });
|
||||
dbContext.TenantLifecycleOperations.Add(operation);
|
||||
AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await InvalidateAsync(tenantId, cancellationToken);
|
||||
return ToItem(operation);
|
||||
}
|
||||
|
||||
private Task<bool> HasRecentExportAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - RecentExportWindow;
|
||||
return dbContext.TenantLifecycleOperations.AnyAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OperationType == TenantLifecycleOperationType.Export &&
|
||||
item.Status == TenantLifecycleOperationStatus.Succeeded &&
|
||||
item.CompletedAt >= cutoff,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Tenant> RequireTenantAsync(Guid tenantId, CancellationToken cancellationToken) =>
|
||||
await dbContext.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ??
|
||||
throw new TenantLifecycleException("tenant_not_found", "Tenant was not found.");
|
||||
|
||||
private async Task RevokeTenantSessionsAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userIds = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.Select(item => item.UserId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
await sessionStore.RevokeRealmAsync(userId, AuthRealm.Tenant, tenantId, "tenant_archived", cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
await featureCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
private static TenantLifecycleOperation CreateOperation(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
TenantLifecycleOperationType type,
|
||||
string? reason) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RequestedBy = actorUserId,
|
||||
OperationType = type,
|
||||
Reason = reason?.Trim()
|
||||
};
|
||||
|
||||
private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason) =>
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ActorUserId = actorUserId,
|
||||
Action = action,
|
||||
TargetType = "tenant",
|
||||
TargetId = targetId.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(new { reason })
|
||||
});
|
||||
|
||||
private static string ToProvider(AssetStorageProvider provider) => provider switch
|
||||
{
|
||||
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||
_ => ObjectStorageProviders.ExternalUrl
|
||||
};
|
||||
|
||||
private static TenantLifecycleOperationItem ToItem(TenantLifecycleOperation operation) => new(
|
||||
operation.Id,
|
||||
operation.TenantId,
|
||||
operation.OperationType,
|
||||
operation.Status,
|
||||
operation.RequestedBy,
|
||||
operation.TargetUserId,
|
||||
operation.ExportAssetId,
|
||||
operation.Reason,
|
||||
operation.LastError,
|
||||
operation.CreatedAt,
|
||||
operation.CompletedAt);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Growth;
|
||||
@@ -36,6 +37,7 @@ public sealed class ApiTestFactory(
|
||||
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
|
||||
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
|
||||
ISmsProvider? smsProvider = null,
|
||||
IAssetSecurityScanner? assetSecurityScanner = null,
|
||||
IReadOnlyDictionary<string, string?>? configurationOverrides = null,
|
||||
DbCommandInterceptor? dbCommandInterceptor = null) : WebApplicationFactory<ApiProgramMarker>
|
||||
{
|
||||
@@ -56,7 +58,6 @@ public sealed class ApiTestFactory(
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["BackgroundProcessing:Enabled"] = "false",
|
||||
["Security:Jwt:KeyId"] = TestJwtKeys.KeyId,
|
||||
["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem,
|
||||
["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api"
|
||||
@@ -143,6 +144,12 @@ public sealed class ApiTestFactory(
|
||||
services.RemoveAll<ISmsProvider>();
|
||||
services.AddSingleton(smsProvider);
|
||||
}
|
||||
|
||||
if (assetSecurityScanner is not null)
|
||||
{
|
||||
services.RemoveAll<IAssetSecurityScanner>();
|
||||
services.AddSingleton(assetSecurityScanner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,29 @@ public sealed class AssetAccessEndpointTests
|
||||
Assert.Equal("asset_preview_not_supported", body.RootElement.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AssetSecurityScanStatus.Pending)]
|
||||
[InlineData(AssetSecurityScanStatus.Scanning)]
|
||||
[InlineData(AssetSecurityScanStatus.Failed)]
|
||||
[InlineData(AssetSecurityScanStatus.Skipped)]
|
||||
public async Task Asset_access_fails_closed_until_security_scan_is_trusted(
|
||||
AssetSecurityScanStatus scanStatus)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var assetId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
var asset = PublicAsset(tenantId, assetId);
|
||||
asset.SecurityScanStatus = scanStatus;
|
||||
await factory.SeedAsync(Tenant(tenantId, "scan-gate"), asset);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=scan-gate");
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal("asset_security_scan_not_passed", body.RootElement.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
private static ContentAsset PublicAsset(Guid tenantId, Guid assetId)
|
||||
{
|
||||
return new ContentAsset
|
||||
|
||||
@@ -128,6 +128,11 @@ public sealed class AssetManagementEndpointTests
|
||||
Assert.Equal(seed.UserId, asset.VerifiedBy);
|
||||
Assert.Equal(2048, asset.VerifiedSizeBytes);
|
||||
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
|
||||
var scanJob = Assert.Single(dbContext.BackgroundJobs.Where(job =>
|
||||
job.TenantId == seed.TenantId && job.JobType == "asset_security_scan"));
|
||||
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
|
||||
Assert.Equal(assetId, scanJob.Payload.GetProperty("assetId").GetGuid());
|
||||
Assert.StartsWith($"asset:{assetId:N}:", scanJob.IdempotencyKey, StringComparison.Ordinal);
|
||||
Assert.False(dbContext.TenantFeatureUsages.Any(value =>
|
||||
value.TenantId == seed.TenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes));
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ public sealed class AuthPasswordLifecycleTests
|
||||
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
|
||||
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
|
||||
[InlineData(nameof(AuthController.ChangeRequiredPassword), "password/change-required")]
|
||||
[InlineData(nameof(AuthController.SendPasswordResetCode), "password/reset/sms/send")]
|
||||
[InlineData(nameof(AuthController.ResetPassword), "password/reset")]
|
||||
[InlineData(nameof(AuthController.ChangePassword), "password/change")]
|
||||
[InlineData(nameof(AuthController.Refresh), "refresh")]
|
||||
[InlineData(nameof(AuthController.Logout), "logout")]
|
||||
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]
|
||||
|
||||
289
Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs
Normal file
289
Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthRecoveryAndDeviceEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Password_reset_send_does_not_reveal_account_existence()
|
||||
{
|
||||
var provider = new CapturingSmsProvider();
|
||||
await using var factory = new ApiTestFactory(smsProvider: provider);
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
|
||||
|
||||
var existing = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/reset/sms/send",
|
||||
new PasswordResetSmsSendDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
DeviceId = "known-device"
|
||||
});
|
||||
var missing = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/reset/sms/send",
|
||||
new PasswordResetSmsSendDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = "13999999999",
|
||||
DeviceId = "unknown-device"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, existing.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Accepted, missing.StatusCode);
|
||||
Assert.Equal(1, provider.SendCount);
|
||||
Assert.DoesNotContain(provider.Code!, await existing.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(provider.Code!, await missing.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_reset_consumes_reset_code_and_revokes_existing_sessions()
|
||||
{
|
||||
var provider = new CapturingSmsProvider();
|
||||
await using var factory = new ApiTestFactory(smsProvider: provider);
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
|
||||
var send = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/reset/sms/send",
|
||||
new PasswordResetSmsSendDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
DeviceId = "reset-device"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Accepted, send.StatusCode);
|
||||
Assert.NotNull(provider.Code);
|
||||
|
||||
var resetRequest = new PasswordResetDto
|
||||
{
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Code = provider.Code!,
|
||||
NewPassword = "ResetPassword2026"
|
||||
};
|
||||
var reset = await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest);
|
||||
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
|
||||
|
||||
client.UseAccessToken(oldTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await PostPasswordLoginAsync(client, seed, resetRequest.NewPassword)).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticated_password_change_rotates_to_a_new_session()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
client.UseAccessToken(oldTokens);
|
||||
|
||||
var changed = await client.PostAsJsonAsync(
|
||||
"/api/auth/password/change",
|
||||
new AuthenticatedPasswordChangeDto
|
||||
{
|
||||
CurrentPassword = PasswordTestUserExtensions.TestPassword,
|
||||
NewPassword = "ChangedPassword2026"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
|
||||
using var body = JsonDocument.Parse(await changed.Content.ReadAsStringAsync());
|
||||
var accessToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
var refreshToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("refreshToken").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(accessToken));
|
||||
Assert.False(string.IsNullOrWhiteSpace(refreshToken));
|
||||
|
||||
client.UseAccessToken(oldTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode);
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/me")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Unauthorized,
|
||||
(await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.OK,
|
||||
(await PostPasswordLoginAsync(client, seed, "ChangedPassword2026")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_sessions_are_scoped_and_only_other_owned_families_can_be_revoked()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedUserAsync(factory);
|
||||
var other = await SeedUserAsync(factory);
|
||||
using var firstClient = factory.CreateClient();
|
||||
using var secondClient = factory.CreateClient();
|
||||
using var otherClient = factory.CreateClient();
|
||||
_ = await firstClient.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
var secondTokens = await secondClient.LoginAsTenantAsync(seed.TenantId, seed.Phone);
|
||||
var otherTokens = await otherClient.LoginAsTenantAsync(other.TenantId, other.Phone);
|
||||
secondClient.UseAccessToken(secondTokens);
|
||||
otherClient.UseAccessToken(otherTokens);
|
||||
|
||||
using var sessions = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync());
|
||||
var items = sessions.RootElement.EnumerateArray().ToArray();
|
||||
Assert.Equal(2, items.Length);
|
||||
var currentFamily = items.Single(item => item.GetProperty("isCurrent").GetBoolean())
|
||||
.GetProperty("sessionFamilyId").GetGuid();
|
||||
var otherOwnedFamily = items.Single(item => !item.GetProperty("isCurrent").GetBoolean())
|
||||
.GetProperty("sessionFamilyId").GetGuid();
|
||||
|
||||
Assert.Equal(
|
||||
HttpStatusCode.Conflict,
|
||||
(await secondClient.DeleteAsync($"/api/me/sessions/{currentFamily}")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.NotFound,
|
||||
(await otherClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode);
|
||||
Assert.Equal(
|
||||
HttpStatusCode.NoContent,
|
||||
(await secondClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode);
|
||||
using var remaining = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync());
|
||||
Assert.Single(remaining.RootElement.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_administrator_reset_requires_same_tenant_and_forces_password_change()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var admin = await SeedUserAsync(factory);
|
||||
var target = await SeedMemberAsync(factory, admin.TenantId, TenantRole.Teacher);
|
||||
var crossTenantTarget = await SeedUserAsync(factory);
|
||||
using var targetClient = factory.CreateClient();
|
||||
var targetTokens = await targetClient.LoginAsTenantAsync(target.TenantId, target.Phone);
|
||||
using var adminClient = factory.CreateClient();
|
||||
adminClient.UseAccessToken(await adminClient.LoginAsTenantAsync(admin.TenantId, admin.Phone));
|
||||
|
||||
var reset = await adminClient.PostAsJsonAsync(
|
||||
$"/api/tenant-admin/members/{target.UserId}/password-reset",
|
||||
new AdministrativePasswordResetDto
|
||||
{
|
||||
TemporaryPassword = "TemporaryPassword2026",
|
||||
Reason = "Account recovery verification"
|
||||
});
|
||||
var crossTenant = await adminClient.PostAsJsonAsync(
|
||||
$"/api/tenant-admin/members/{crossTenantTarget.UserId}/password-reset",
|
||||
new AdministrativePasswordResetDto
|
||||
{
|
||||
TemporaryPassword = "TemporaryPassword2026",
|
||||
Reason = "Must not cross tenant boundary"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenant.StatusCode);
|
||||
targetClient.UseAccessToken(targetTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode);
|
||||
var temporaryLogin = await PostPasswordLoginAsync(targetClient, target, "TemporaryPassword2026");
|
||||
Assert.Equal(HttpStatusCode.OK, temporaryLogin.StatusCode);
|
||||
Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify administrative password reset");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync());
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
|
||||
item.TenantId == admin.TenantId &&
|
||||
item.ActorUserId == admin.UserId &&
|
||||
item.Action == "auth.password.reset_by_administrator" &&
|
||||
item.TargetId == target.UserId.ToString()));
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostPasswordLoginAsync(
|
||||
HttpClient client,
|
||||
UserSeed seed,
|
||||
string password) =>
|
||||
client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Identifier = seed.Phone,
|
||||
Password = password
|
||||
});
|
||||
|
||||
private static async Task<UserSeed> SeedUserAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
|
||||
Name = "Recovery test user"
|
||||
}.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Recovery test tenant",
|
||||
Status = TenantStatus.Active
|
||||
},
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return new UserSeed(tenantId, user.Id, user.Phone!);
|
||||
}
|
||||
|
||||
private static async Task<UserSeed> SeedMemberAsync(
|
||||
ApiTestFactory factory,
|
||||
Guid tenantId,
|
||||
TenantRole role)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
|
||||
Name = "Administrative reset target"
|
||||
}.WithTestPassword();
|
||||
await factory.SeedAsync(
|
||||
user,
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
Role = role,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
return new UserSeed(tenantId, user.Id, user.Phone!);
|
||||
}
|
||||
|
||||
private sealed record UserSeed(Guid TenantId, Guid UserId, string Phone);
|
||||
|
||||
private sealed class CapturingSmsProvider : ISmsProvider
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
public string? Code { get; private set; }
|
||||
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SendCount++;
|
||||
Code = request.Code;
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "reset-message-id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
private const int ExpectedActionCount = 399;
|
||||
private const string ExpectedSha256 = "e4460d18dbd88cb8a4293c650688f03ddaa67e69a423e70d6675c635e237c316";
|
||||
private const int ExpectedActionCount = 426;
|
||||
private const string ExpectedSha256 = "e45f159b7285342e44f256b63c483c575f9b624f01e5ad9f96c4bfea71603bb7";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
|
||||
@@ -3,8 +3,6 @@ using System.Net;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.BackgroundProcessing;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -14,20 +12,8 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class MonolithBackgroundProcessingTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string?> EnabledConfiguration =
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["BackgroundProcessing:Enabled"] = "true",
|
||||
["BackgroundProcessing:JobPollSeconds"] = "1",
|
||||
["BackgroundProcessing:JobParallelism"] = "2",
|
||||
["BackgroundProcessing:JobBatchSize"] = "2",
|
||||
["TenantDomains:Enabled"] = "false",
|
||||
["SaasSubscriptions:Enabled"] = "false",
|
||||
["FeatureUsageReconciliation:Enabled"] = "false"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Readiness_reports_only_postgres_and_redis_dependencies()
|
||||
public async Task Anonymous_readiness_is_minimal_and_does_not_expose_dependency_topology()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
@@ -36,81 +22,26 @@ public sealed class MonolithBackgroundProcessingTests
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(document.RootElement.TryGetProperty("database", out _));
|
||||
Assert.True(document.RootElement.TryGetProperty("redis", out _));
|
||||
Assert.Equal("ready", document.RootElement.GetProperty("status").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("database", out _));
|
||||
Assert.False(document.RootElement.TryGetProperty("redis", out _));
|
||||
Assert.False(document.RootElement.TryGetProperty("rabbitMq", out _));
|
||||
Assert.False(document.RootElement.TryGetProperty("outbox", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Background_processing_registration_obeys_master_switch()
|
||||
public async Task Api_host_does_not_register_background_processors()
|
||||
{
|
||||
await using var disabledFactory = new ApiTestFactory();
|
||||
using var disabledClient = disabledFactory.CreateClient();
|
||||
var disabledNames = disabledFactory.Services.GetServices<IHostedService>()
|
||||
.Select(service => service.GetType().Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.False(disabledFactory.Services.GetRequiredService<IOptions<BackgroundProcessingOptions>>().Value.Enabled);
|
||||
Assert.Contains("TenantDomainBackgroundService", disabledNames);
|
||||
Assert.Contains("SaasSubscriptionBackgroundService", disabledNames);
|
||||
Assert.Contains("FeatureUsageBackgroundService", disabledNames);
|
||||
Assert.Contains("BackgroundJobsBackgroundService", disabledNames);
|
||||
|
||||
await using var enabledFactory = new ApiTestFactory(configurationOverrides: EnabledConfiguration);
|
||||
using var enabledClient = enabledFactory.CreateClient();
|
||||
var enabledNames = enabledFactory.Services.GetServices<IHostedService>()
|
||||
.Select(service => service.GetType().Name)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(enabledFactory.Services.GetRequiredService<IOptions<BackgroundProcessingOptions>>().Value.Enabled);
|
||||
Assert.Contains("TenantDomainBackgroundService", enabledNames);
|
||||
Assert.Contains("SaasSubscriptionBackgroundService", enabledNames);
|
||||
Assert.Contains("FeatureUsageBackgroundService", enabledNames);
|
||||
Assert.Contains("BackgroundJobsBackgroundService", enabledNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_host_processes_immediate_and_due_postgres_jobs()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: EnabledConfiguration);
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Monolith Background Processing"
|
||||
});
|
||||
var hostedServiceNames = factory.Services.GetServices<IHostedService>()
|
||||
.Select(service => service.GetType().Name)
|
||||
.ToArray();
|
||||
|
||||
BackgroundJobItem immediate;
|
||||
BackgroundJobItem delayed;
|
||||
using (var scope = factory.CreateSystemScope("Queue monolith background jobs"))
|
||||
{
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
immediate = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" })));
|
||||
delayed = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" }),
|
||||
DateTimeOffset.UtcNow.AddMinutes(5)));
|
||||
}
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, await WaitForStatusAsync(factory, immediate.Id));
|
||||
Assert.Equal(BackgroundJobStatus.Pending, await ReadStatusAsync(factory, delayed.Id));
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Make delayed monolith job due"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
await dbContext.BackgroundJobs
|
||||
.Where(job => job.Id == delayed.Id)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(job => job.RunAfter, DateTimeOffset.UtcNow.AddSeconds(-1)));
|
||||
}
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, await WaitForStatusAsync(factory, delayed.Id));
|
||||
Assert.DoesNotContain("TenantDomainWorker", hostedServiceNames);
|
||||
Assert.DoesNotContain("SaasSubscriptionWorker", hostedServiceNames);
|
||||
Assert.DoesNotContain("FeatureUsageWorker", hostedServiceNames);
|
||||
Assert.DoesNotContain("BackgroundJobsWorker", hostedServiceNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -163,23 +94,6 @@ public sealed class MonolithBackgroundProcessingTests
|
||||
.ProcessPendingAsync(workerId, 1);
|
||||
}
|
||||
|
||||
private static async Task<BackgroundJobStatus> WaitForStatusAsync(ApiTestFactory factory, Guid jobId)
|
||||
{
|
||||
var timeout = DateTimeOffset.UtcNow.AddSeconds(10);
|
||||
while (DateTimeOffset.UtcNow < timeout)
|
||||
{
|
||||
var status = await ReadStatusAsync(factory, jobId);
|
||||
if (status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed)
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
return await ReadStatusAsync(factory, jobId);
|
||||
}
|
||||
|
||||
private static async Task<BackgroundJobStatus> ReadStatusAsync(ApiTestFactory factory, Guid jobId)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Read monolith background job status");
|
||||
|
||||
171
Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs
Normal file
171
Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class P0AssetSecurityScanTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AssetSecurityScanVerdict.Clean, AssetSecurityScanStatus.Passed, AssetSecurityRiskLevel.None)]
|
||||
[InlineData(AssetSecurityScanVerdict.Infected, AssetSecurityScanStatus.Failed, AssetSecurityRiskLevel.Critical)]
|
||||
public async Task Asset_scan_persists_terminal_verdict_and_audit_event(
|
||||
AssetSecurityScanVerdict verdict,
|
||||
AssetSecurityScanStatus expectedStatus,
|
||||
AssetSecurityRiskLevel expectedRisk)
|
||||
{
|
||||
var scanner = new FakeScanner(new AssetSecurityScanResult(
|
||||
verdict,
|
||||
"clamav",
|
||||
verdict == AssetSecurityScanVerdict.Infected ? "Eicar-Signature" : null,
|
||||
12,
|
||||
verdict == AssetSecurityScanVerdict.Infected ? "stream: Eicar-Signature FOUND" : "stream: OK"));
|
||||
await using var factory = new ApiTestFactory(
|
||||
objectStorageService: new ReadableStorage(),
|
||||
assetSecurityScanner: scanner);
|
||||
var (tenantId, assetId, jobId) = await SeedScanAsync(factory);
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Process asset security scan"))
|
||||
{
|
||||
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync("asset-scan-test", 10));
|
||||
}
|
||||
|
||||
using var verifyScope = factory.CreateSystemScope("Verify asset security scan");
|
||||
var dbContext = verifyScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = await dbContext.ContentAssets.AsNoTracking().SingleAsync(item => item.Id == assetId);
|
||||
var job = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == jobId);
|
||||
var scanEvent = await dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.SingleAsync(item => item.TenantId == tenantId && item.AssetId == assetId);
|
||||
|
||||
Assert.Equal(expectedStatus, asset.SecurityScanStatus);
|
||||
Assert.Equal("clamav", asset.SecurityScanProvider);
|
||||
Assert.NotNull(asset.SecurityScannedAt);
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, job.Status);
|
||||
Assert.Equal(expectedStatus, scanEvent.ScanStatus);
|
||||
Assert.Equal(expectedRisk, scanEvent.RiskLevel);
|
||||
if (verdict == AssetSecurityScanVerdict.Infected)
|
||||
{
|
||||
Assert.Contains("Eicar-Signature", scanEvent.IssueCodes);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(scanEvent.IssueCodes);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unavailable_scanner_keeps_asset_pending_and_schedules_job_retry()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(
|
||||
objectStorageService: new ReadableStorage(),
|
||||
assetSecurityScanner: new FakeScanner(new AssetSecurityScannerException(
|
||||
"clamav_unavailable",
|
||||
"ClamAV is unavailable.")));
|
||||
var (tenantId, assetId, jobId) = await SeedScanAsync(factory);
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Process unavailable asset security scan"))
|
||||
{
|
||||
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync("asset-scan-test", 10));
|
||||
}
|
||||
|
||||
using var verifyScope = factory.CreateSystemScope("Verify asset security scan retry");
|
||||
var dbContext = verifyScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = await dbContext.ContentAssets.AsNoTracking().SingleAsync(item => item.Id == assetId);
|
||||
var job = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == jobId);
|
||||
var scanEvent = await dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.SingleAsync(item => item.TenantId == tenantId && item.AssetId == assetId);
|
||||
|
||||
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
|
||||
Assert.Equal(BackgroundJobStatus.Pending, job.Status);
|
||||
Assert.Equal(1, job.RetryCount);
|
||||
Assert.NotNull(job.RunAfter);
|
||||
Assert.Contains("clamav_unavailable", job.LastError);
|
||||
Assert.Equal(AssetSecurityScanStatus.Pending, scanEvent.ScanStatus);
|
||||
Assert.Contains("clamav_unavailable", scanEvent.IssueCodes);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid AssetId, Guid JobId)> SeedScanAsync(ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var assetId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Asset scan tenant",
|
||||
Status = TenantStatus.Active
|
||||
},
|
||||
new ContentAsset
|
||||
{
|
||||
Id = assetId,
|
||||
TenantId = tenantId,
|
||||
Title = "Scannable asset",
|
||||
FileName = "scan.txt",
|
||||
StorageProvider = AssetStorageProvider.LocalDev,
|
||||
Bucket = "tenant-assets",
|
||||
ObjectKey = $"{tenantId:N}/assets/scan.txt",
|
||||
MimeType = "text/plain",
|
||||
FileSizeBytes = 12,
|
||||
VerifiedSizeBytes = 12,
|
||||
UploadStatus = AssetUploadStatus.Verified,
|
||||
SecurityScanStatus = AssetSecurityScanStatus.Pending
|
||||
});
|
||||
|
||||
using var scope = factory.CreateSystemScope("Queue asset security scan");
|
||||
var job = await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>().EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"asset_security_scan",
|
||||
JsonSerializer.SerializeToElement(new { assetId }),
|
||||
MaxRetries: 5,
|
||||
IdempotencyKey: $"asset:{assetId:N}:integration-test",
|
||||
IsSystemJob: true));
|
||||
return (tenantId, assetId, job.Id);
|
||||
}
|
||||
|
||||
private sealed class FakeScanner(object outcome) : IAssetSecurityScanner
|
||||
{
|
||||
public Task<AssetSecurityScanResult> ScanAsync(
|
||||
Stream content,
|
||||
long? declaredLength,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
outcome switch
|
||||
{
|
||||
AssetSecurityScanResult result => Task.FromResult(result),
|
||||
Exception exception => Task.FromException<AssetSecurityScanResult>(exception),
|
||||
_ => throw new InvalidOperationException("Unsupported scanner outcome.")
|
||||
};
|
||||
|
||||
public Task<bool> CheckHealthAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(outcome is AssetSecurityScanResult);
|
||||
}
|
||||
|
||||
private sealed class ReadableStorage : IObjectStorageService
|
||||
{
|
||||
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;
|
||||
public string ConfiguredDefaultBucket() => "tenant-assets";
|
||||
public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev;
|
||||
public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey;
|
||||
public string ValidateMimeType(string mimeType) => mimeType;
|
||||
public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes;
|
||||
public void AssertUploadProvider(string provider) { }
|
||||
public void AssertWritableLocation(StorageAssetLocation location) { }
|
||||
public Task<ObjectStorageSignedUrl> SignUploadAsync(ObjectStorageUploadSignRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<ObjectStorageSignedUrl> SignDownloadAsync(ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<ObjectStorageWriteResult> WriteObjectAsync(ObjectStorageWriteRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<ObjectStorageMetadata> HeadObjectAsync(ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public Task<Stream> OpenReadAsync(ObjectStorageReadRequest request, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("hello world!")));
|
||||
}
|
||||
}
|
||||
136
Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs
Normal file
136
Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class P0OperationsLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Job_idempotency_cancel_retry_and_tenant_scope_follow_the_reviewed_state_machine()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantA = await SeedTenantAsync(factory);
|
||||
var tenantB = await SeedTenantAsync(factory);
|
||||
using var scope = factory.CreateSystemScope("Verify P0 job state machine");
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var command = new CreateBackgroundJobCommand(
|
||||
tenantA.TenantId,
|
||||
"asset_security_scan",
|
||||
JsonSerializer.SerializeToElement(new { assetId = Guid.NewGuid() }),
|
||||
IdempotencyKey: "same-request",
|
||||
IsSystemJob: true);
|
||||
|
||||
var first = await jobs.EnqueueAsync(command);
|
||||
var duplicate = await jobs.EnqueueAsync(command);
|
||||
Assert.Equal(first.Id, duplicate.Id);
|
||||
Assert.Null(await jobs.GetAsync(first.Id, tenantB.TenantId));
|
||||
|
||||
var cancelled = await jobs.RequestCancellationAsync(
|
||||
first.Id, tenantA.TenantId, tenantA.UserId, "No longer required");
|
||||
Assert.Equal(BackgroundJobStatus.Cancelled, cancelled.Status);
|
||||
Assert.NotNull(cancelled.CancellationRequestedAt);
|
||||
var retried = await jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId);
|
||||
Assert.Equal(BackgroundJobStatus.Pending, retried.Status);
|
||||
Assert.Null(retried.CancellationRequestedAt);
|
||||
await Assert.ThrowsAsync<BackgroundJobException>(() =>
|
||||
jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId));
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
await dbContext.BackgroundJobs.Where(item => item.Id == first.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Status, BackgroundJobStatus.Processing)
|
||||
.SetProperty(item => item.LockedBy, "active-worker")
|
||||
.SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.AddMinutes(5)));
|
||||
var cooperative = await jobs.RequestCancellationAsync(
|
||||
first.Id, tenantA.TenantId, tenantA.UserId, "Stop at the next cooperative boundary");
|
||||
Assert.Equal(BackgroundJobStatus.Processing, cooperative.Status);
|
||||
Assert.NotNull(cooperative.CancellationRequestedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_export_archive_restore_and_owner_transfer_preserve_lifecycle_invariants()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(
|
||||
configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Storage:DefaultProvider"] = "local_dev",
|
||||
["Storage:DefaultBucket"] = "tenant-assets"
|
||||
});
|
||||
var seed = await SeedTenantAsync(factory, includeSecondMember: true);
|
||||
Guid exportOperationId;
|
||||
using (var scope = factory.CreateSystemScope("Create tenant export operation"))
|
||||
{
|
||||
var lifecycle = scope.ServiceProvider.GetRequiredService<ITenantLifecycleService>();
|
||||
var blocked = await lifecycle.PreviewArchiveAsync(seed.TenantId);
|
||||
Assert.False(blocked.CanArchive);
|
||||
Assert.Contains("recent_successful_export_required", blocked.Blockers);
|
||||
exportOperationId = (await lifecycle.CreateExportAsync(seed.TenantId, seed.UserId)).Id;
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Process tenant export operation"))
|
||||
{
|
||||
var processed = await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync("tenant-export-test", 10);
|
||||
Assert.Equal(1, processed);
|
||||
}
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Archive restore and transfer tenant"))
|
||||
{
|
||||
var lifecycle = scope.ServiceProvider.GetRequiredService<ITenantLifecycleService>();
|
||||
var export = await lifecycle.GetOperationAsync(seed.TenantId, exportOperationId);
|
||||
var exportJob = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().BackgroundJobs.AsNoTracking()
|
||||
.SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "tenant_export");
|
||||
Assert.True(
|
||||
export!.Status == TenantLifecycleOperationStatus.Succeeded,
|
||||
$"Export status={export.Status}, operationError={export.LastError}, jobStatus={exportJob.Status}, jobError={exportJob.LastError}");
|
||||
Assert.NotNull(export.ExportAssetId);
|
||||
Assert.True((await lifecycle.PreviewArchiveAsync(seed.TenantId)).CanArchive);
|
||||
await lifecycle.ArchiveAsync(seed.TenantId, seed.UserId, "Contract ended");
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(TenantStatus.Archived, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status).SingleAsync());
|
||||
Assert.All(await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status).ToArrayAsync(),
|
||||
status => Assert.Equal(TenantDomainStatus.Disabled, status));
|
||||
|
||||
await lifecycle.RestoreAsync(seed.TenantId, seed.UserId, "Customer returned");
|
||||
Assert.Equal(TenantStatus.Suspended, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status).SingleAsync());
|
||||
Assert.All(await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status).ToArrayAsync(),
|
||||
status => Assert.Equal(TenantDomainStatus.Pending, status));
|
||||
|
||||
await lifecycle.TransferOwnerAsync(seed.TenantId, seed.UserId, seed.SecondUserId!.Value, "Ownership handover");
|
||||
Assert.Equal(seed.SecondUserId, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.OwnerUserId).SingleAsync());
|
||||
Assert.Equal(TenantRole.TenantAdmin, await dbContext.TenantMemberships.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId).Select(item => item.Role).SingleAsync());
|
||||
Assert.Equal(TenantRole.TenantOwner, await dbContext.TenantMemberships.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.SecondUserId).Select(item => item.Role).SingleAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TenantSeed> SeedTenantAsync(ApiTestFactory factory, bool includeSecondMember = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var secondUserId = includeSecondMember ? Guid.NewGuid() : (Guid?)null;
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Lifecycle tenant", OwnerUserId = userId },
|
||||
new User { Id = userId, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Owner" }.WithTestPassword(),
|
||||
new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantOwner, Status = MembershipStatus.Active },
|
||||
new TenantDomain { TenantId = tenantId, Host = $"{tenantId:N}.example.test", Status = TenantDomainStatus.Active, IsPrimary = true }
|
||||
};
|
||||
if (secondUserId.HasValue)
|
||||
{
|
||||
entities.Add(new User { Id = secondUserId.Value, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Next owner" }.WithTestPassword());
|
||||
entities.Add(new TenantMembership { TenantId = tenantId, UserId = secondUserId.Value, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active });
|
||||
}
|
||||
await factory.SeedAsync(entities.ToArray());
|
||||
return new TenantSeed(tenantId, userId, secondUserId);
|
||||
}
|
||||
|
||||
private sealed record TenantSeed(Guid TenantId, Guid UserId, Guid? SecondUserId);
|
||||
}
|
||||
@@ -19,6 +19,53 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class PlatformAdminEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_staff_password_reset_revokes_sessions_and_requires_change()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
var administrator = await SeedPlatformAdminAsync(factory);
|
||||
var target = await SeedAdditionalPlatformUserAsync(factory);
|
||||
using var targetClient = factory.CreateClient();
|
||||
var targetTokens = await targetClient.LoginAsPlatformAsync(target.Email);
|
||||
using var adminClient = factory.CreateClient();
|
||||
adminClient.UseAccessToken(await adminClient.LoginAsPlatformAsync(administrator.Email));
|
||||
|
||||
var reset = await adminClient.PostAsJsonAsync(
|
||||
$"/api/platform-admin/staff/{target.UserId}/password-reset",
|
||||
new AdministrativePasswordResetDto
|
||||
{
|
||||
TemporaryPassword = "TemporaryPassword2026",
|
||||
Reason = "Platform staff recovery verification"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
|
||||
targetClient.UseAccessToken(targetTokens);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode);
|
||||
targetClient.DefaultRequestHeaders.Authorization = null;
|
||||
var login = await targetClient.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Platform,
|
||||
Identifier = target.Email,
|
||||
Password = "TemporaryPassword2026"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
Assert.Contains("password_change_required", await login.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify platform administrative password reset");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync());
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
|
||||
item.TenantId == null &&
|
||||
item.ActorUserId == administrator.UserId &&
|
||||
item.Action == "auth.password.reset_by_administrator" &&
|
||||
item.TargetId == target.UserId.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_super_admin_can_load_every_platform_console_bootstrap_endpoint()
|
||||
{
|
||||
@@ -506,6 +553,43 @@ public sealed class PlatformAdminEndpointTests
|
||||
return await SeedPlatformUserAsync(factory, BackendPermissions.Platform);
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedAdditionalPlatformUserAsync(ApiTestFactory factory)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var email = $"platform-target-{Guid.NewGuid():N}@example.test";
|
||||
await factory.SeedAsync(
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Email = email,
|
||||
NormalizedEmail = email.ToUpperInvariant(),
|
||||
UserName = email,
|
||||
NormalizedUserName = email.ToUpperInvariant(),
|
||||
Name = "Platform Reset Target",
|
||||
PrimaryRole = "platform_staff",
|
||||
RawProfile = JsonDefaults.Object()
|
||||
}.WithTestPassword(),
|
||||
new PlatformBackendRole
|
||||
{
|
||||
Id = roleId,
|
||||
Code = $"platform_reset_target_{roleId:N}",
|
||||
Name = "Platform Reset Target",
|
||||
Status = BackendRoleStatus.Active
|
||||
},
|
||||
new PlatformBackendRolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
PermissionCode = BackendPermissions.PlatformDashboardView
|
||||
},
|
||||
new PlatformBackendUserRole
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = roleId
|
||||
});
|
||||
return (userId, email);
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(
|
||||
ApiTestFactory factory,
|
||||
IEnumerable<string> platformPermissions)
|
||||
|
||||
@@ -23,13 +23,20 @@ public sealed class BuiltinBackofficeCatalogSeederTests
|
||||
await seeder.SeedAsync();
|
||||
|
||||
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
|
||||
Assert.Equal(26, await dbContext.PermissionModules.CountAsync());
|
||||
Assert.Equal(35, await dbContext.BackendPermissions.CountAsync());
|
||||
Assert.Equal(27, await dbContext.PermissionModules.CountAsync());
|
||||
Assert.Equal(37, await dbContext.BackendPermissions.CountAsync());
|
||||
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
|
||||
Assert.Equal(15, await dbContext.SaasFeatures.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(26, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(35, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(27, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(37, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.Equal(21, await dbContext.BackendMenus.Select(item => item.Code).Distinct().CountAsync());
|
||||
Assert.True(await dbContext.PermissionModules.AnyAsync(item => item.Code == "platform_operations"));
|
||||
Assert.True(await dbContext.BackendPermissions.AnyAsync(item =>
|
||||
item.Code == BackendPermissions.PlatformOperationsView &&
|
||||
item.PermissionModuleCode == "platform_operations"));
|
||||
Assert.True(await dbContext.BackendPermissions.AnyAsync(item =>
|
||||
item.Code == BackendPermissions.PlatformOperationsManage &&
|
||||
item.PermissionModuleCode == "platform_operations"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -82,8 +89,8 @@ public sealed class BuiltinBackofficeCatalogSeederTests
|
||||
Assert.Equal("Custom menu title", (await dbContext.BackendMenus.SingleAsync(
|
||||
item => item.Code == "tenant.dashboard")).Title);
|
||||
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
|
||||
Assert.Equal(26, await dbContext.PermissionModules.CountAsync());
|
||||
Assert.Equal(35, await dbContext.BackendPermissions.CountAsync());
|
||||
Assert.Equal(27, await dbContext.PermissionModules.CountAsync());
|
||||
Assert.Equal(37, await dbContext.BackendPermissions.CountAsync());
|
||||
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
|
||||
Assert.False(await dbContext.PermissionModules.AnyAsync(module =>
|
||||
module.RequiredFeatureCode != null &&
|
||||
|
||||
108
Tiku.UnitTests/Assets/ClamAvAssetSecurityScannerTests.cs
Normal file
108
Tiku.UnitTests/Assets/ClamAvAssetSecurityScannerTests.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
|
||||
namespace Tiku.UnitTests.Assets;
|
||||
|
||||
public sealed class ClamAvAssetSecurityScannerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("stream: OK", AssetSecurityScanVerdict.Clean, null)]
|
||||
[InlineData("stream: Eicar-Signature FOUND", AssetSecurityScanVerdict.Infected, "Eicar-Signature")]
|
||||
public async Task Instream_protocol_parses_clean_and_infected_responses(
|
||||
string response,
|
||||
AssetSecurityScanVerdict expectedVerdict,
|
||||
string? expectedSignature)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = ServeScanAsync(listener, response);
|
||||
var scanner = CreateScanner(port);
|
||||
var payload = Encoding.UTF8.GetBytes("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*");
|
||||
|
||||
var result = await scanner.ScanAsync(new MemoryStream(payload), payload.Length);
|
||||
await server;
|
||||
|
||||
Assert.Equal(expectedVerdict, result.Verdict);
|
||||
Assert.Equal(expectedSignature, result.Signature);
|
||||
Assert.Equal(payload.Length, result.BytesScanned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_requires_pong()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = Task.Run(async () =>
|
||||
{
|
||||
using var client = await listener.AcceptTcpClientAsync();
|
||||
await using var stream = client.GetStream();
|
||||
Assert.Equal("zPING\0", Encoding.ASCII.GetString(await ReadExactAsync(stream, 6)));
|
||||
await stream.WriteAsync("PONG\0"u8.ToArray());
|
||||
listener.Stop();
|
||||
});
|
||||
|
||||
Assert.True(await CreateScanner(port).CheckHealthAsync());
|
||||
await server;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Oversized_stream_is_rejected_before_connecting()
|
||||
{
|
||||
var scanner = CreateScanner(1, streamMaxLength: 4);
|
||||
var exception = await Assert.ThrowsAsync<AssetSecurityScannerException>(() =>
|
||||
scanner.ScanAsync(new MemoryStream(new byte[5]), 5));
|
||||
Assert.Equal("clamav_stream_too_large", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unavailable_daemon_is_reported_as_transient_scanner_failure()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
var exception = await Assert.ThrowsAsync<AssetSecurityScannerException>(() =>
|
||||
CreateScanner(port).ScanAsync(new MemoryStream([1]), 1));
|
||||
Assert.Equal("clamav_unavailable", exception.Code);
|
||||
}
|
||||
|
||||
private static ClamAvAssetSecurityScanner CreateScanner(int port, long streamMaxLength = 1024 * 1024) =>
|
||||
new(Options.Create(new ClamAvOptions
|
||||
{
|
||||
Host = IPAddress.Loopback.ToString(),
|
||||
Port = port,
|
||||
TimeoutSeconds = 2,
|
||||
ChunkBytes = 1024,
|
||||
StreamMaxLength = streamMaxLength
|
||||
}));
|
||||
|
||||
private static Task ServeScanAsync(TcpListener listener, string response) => Task.Run(async () =>
|
||||
{
|
||||
using var client = await listener.AcceptTcpClientAsync();
|
||||
await using var stream = client.GetStream();
|
||||
Assert.Equal("zINSTREAM\0", Encoding.ASCII.GetString(await ReadExactAsync(stream, 10)));
|
||||
while (true)
|
||||
{
|
||||
var lengthBytes = await ReadExactAsync(stream, 4);
|
||||
var length = BinaryPrimitives.ReadUInt32BigEndian(lengthBytes);
|
||||
if (length == 0) break;
|
||||
_ = await ReadExactAsync(stream, checked((int)length));
|
||||
}
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(response + "\0"));
|
||||
listener.Stop();
|
||||
});
|
||||
|
||||
private static async Task<byte[]> ReadExactAsync(Stream stream, int length)
|
||||
{
|
||||
var buffer = new byte[length];
|
||||
await stream.ReadExactlyAsync(buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
24
Tiku.Worker/Dockerfile
Normal file
24
Tiku.Worker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Directory.Build.props", "Directory.Packages.props", "./"]
|
||||
COPY ["Tiku.Domain/Tiku.Domain.csproj", "Tiku.Domain/"]
|
||||
COPY ["Tiku.Application/Tiku.Application.csproj", "Tiku.Application/"]
|
||||
COPY ["Tiku.Infrastructure/Tiku.Infrastructure.csproj", "Tiku.Infrastructure/"]
|
||||
COPY ["Tiku.Worker/Tiku.Worker.csproj", "Tiku.Worker/"]
|
||||
RUN dotnet restore "Tiku.Worker/Tiku.Worker.csproj"
|
||||
COPY . .
|
||||
RUN dotnet publish "Tiku.Worker/Tiku.Worker.csproj" \
|
||||
-c $BUILD_CONFIGURATION \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Tiku.Worker.dll"]
|
||||
26
Tiku.Worker/Program.cs
Normal file
26
Tiku.Worker/Program.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Tiku.Worker;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting TIKU Worker");
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.AddWorkerServices();
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Fatal(exception, "TIKU Worker terminated unexpectedly");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Log.CloseAndFlushAsync();
|
||||
}
|
||||
24
Tiku.Worker/Tiku.Worker.csproj
Normal file
24
Tiku.Worker/Tiku.Worker.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
119
Tiku.Worker/WorkerDependencyInjection.cs
Normal file
119
Tiku.Worker/WorkerDependencyInjection.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Serilog;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using OpenTelemetry.Metrics;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
internal static class WorkerDependencyInjection
|
||||
{
|
||||
internal static HostApplicationBuilder AddWorkerServices(this HostApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddSerilog((services, configuration) => configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext(),
|
||||
preserveStaticLogger: true);
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("Database") ??
|
||||
builder.Configuration["DATABASE_URL"] ??
|
||||
(builder.Environment.IsDevelopment()
|
||||
? $"Host=localhost;Database=tiku;Username={Environment.UserName}"
|
||||
: throw new InvalidOperationException(
|
||||
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"];
|
||||
var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(WorkerTelemetry.MeterName));
|
||||
if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint))
|
||||
{
|
||||
telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
|
||||
}
|
||||
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<ObjectStorageOptions>()
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() ||
|
||||
options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
"Production managed storage must use the configured Aliyun OSS provider.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<AliyunOssOptions>()
|
||||
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
|
||||
(aliyun, storage) =>
|
||||
!builder.Environment.IsProduction() ||
|
||||
storage.Value.DefaultProvider != Tiku.Application.Storage.ObjectStorageProviders.AliyunOss ||
|
||||
aliyun.IsConfigured,
|
||||
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<ClamAvOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ClamAvOptions.SectionName))
|
||||
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
|
||||
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
|
||||
(clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes,
|
||||
"ClamAV StreamMaxLength must cover the storage max upload size.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<WorkerOptions>()
|
||||
.Bind(builder.Configuration.GetSection(WorkerOptions.SectionName))
|
||||
.Validate(WorkerOptions.BeValid, "Worker settings are invalid.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
|
||||
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
|
||||
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
|
||||
builder.Services.AddSingleton<IPeriodicProcessorLock, PostgresPeriodicProcessorLock>();
|
||||
builder.Services.AddSingleton<WorkerStateReporter>();
|
||||
builder.Services.AddHostedService<TenantDomainWorker>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionWorker>();
|
||||
builder.Services.AddHostedService<FeatureUsageWorker>();
|
||||
builder.Services.AddHostedService<BackgroundJobsWorker>();
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string[] SplitLegacyList(string? value, string[] fallback) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
264
Tiku.Worker/WorkerServices.cs
Normal file
264
Tiku.Worker/WorkerServices.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public const string SectionName = "Worker";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int JobPollSeconds { get; set; } = 2;
|
||||
public int JobParallelism { get; set; } = 4;
|
||||
public int JobBatchSize { get; set; } = 5;
|
||||
|
||||
public static bool BeValid(WorkerOptions options) =>
|
||||
options.JobPollSeconds is >= 1 and <= 3600 &&
|
||||
options.JobParallelism is >= 1 and <= 32 &&
|
||||
options.JobBatchSize is >= 1 and <= 100;
|
||||
}
|
||||
|
||||
internal interface IPeriodicProcessorLock
|
||||
{
|
||||
Task<IAsyncDisposable?> TryAcquireAsync(string processor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class WorkerStateReporter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Environment.ProcessId}";
|
||||
private readonly DateTimeOffset startedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
public Task StartedAsync(string processor, CancellationToken cancellationToken) =>
|
||||
UpsertAsync(processor, true, null, cancellationToken);
|
||||
|
||||
public Task CompletedAsync(string processor, Exception? error, CancellationToken cancellationToken) =>
|
||||
UpsertAsync(processor, false, error?.Message, cancellationToken);
|
||||
|
||||
private async Task UpsertAsync(
|
||||
string processor,
|
||||
bool running,
|
||||
string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO worker_heartbeats
|
||||
(id, worker_id, processor, started_at, last_heartbeat_at, last_iteration_started_at,
|
||||
last_iteration_completed_at, last_succeeded_at, last_error, is_running)
|
||||
VALUES
|
||||
(gen_random_uuid(), @worker_id, @processor, @started_at, @now,
|
||||
CASE WHEN @running THEN @now ELSE NULL END,
|
||||
CASE WHEN @running THEN NULL ELSE @now END,
|
||||
CASE WHEN NOT @running AND @error IS NULL THEN @now ELSE NULL END,
|
||||
@error, @running)
|
||||
ON CONFLICT (worker_id, processor) DO UPDATE SET
|
||||
last_heartbeat_at = EXCLUDED.last_heartbeat_at,
|
||||
last_iteration_started_at = CASE WHEN EXCLUDED.is_running THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_iteration_started_at END,
|
||||
last_iteration_completed_at = CASE WHEN EXCLUDED.is_running THEN worker_heartbeats.last_iteration_completed_at ELSE EXCLUDED.last_heartbeat_at END,
|
||||
last_succeeded_at = CASE WHEN NOT EXCLUDED.is_running AND EXCLUDED.last_error IS NULL THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_succeeded_at END,
|
||||
last_error = EXCLUDED.last_error,
|
||||
is_running = EXCLUDED.is_running
|
||||
""";
|
||||
command.Parameters.AddWithValue("worker_id", workerId);
|
||||
command.Parameters.AddWithValue("processor", processor);
|
||||
command.Parameters.AddWithValue("started_at", startedAt);
|
||||
command.Parameters.AddWithValue("now", now);
|
||||
command.Parameters.AddWithValue("running", running);
|
||||
command.Parameters.AddWithValue("error", (object?)error ?? DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PostgresPeriodicProcessorLock(NpgsqlDataSource dataSource) : IPeriodicProcessorLock
|
||||
{
|
||||
public async Task<IAsyncDisposable?> TryAcquireAsync(string processor, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT pg_try_advisory_lock(hashtextextended(@processor, 0))";
|
||||
command.Parameters.AddWithValue("processor", processor);
|
||||
var acquired = (bool)(await command.ExecuteScalarAsync(cancellationToken) ?? false);
|
||||
if (!acquired)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryLockLease(connection, processor);
|
||||
}
|
||||
|
||||
private sealed class AdvisoryLockLease(NpgsqlConnection connection, string processor) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (connection.State == ConnectionState.Open)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT pg_advisory_unlock(hashtextextended(@processor, 0))";
|
||||
command.Parameters.AddWithValue("processor", processor);
|
||||
await command.ExecuteScalarAsync();
|
||||
}
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract class PeriodicWorker(
|
||||
ILogger logger,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
string processorName,
|
||||
TimeSpan interval,
|
||||
bool enabled) : BackgroundService
|
||||
{
|
||||
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var lease = await processorLock.TryAcquireAsync(processorName, stoppingToken);
|
||||
if (lease is not null)
|
||||
{
|
||||
var iterationTimestamp = Stopwatch.GetTimestamp();
|
||||
await stateReporter.StartedAsync(processorName, stoppingToken);
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
await stateReporter.CompletedAsync(processorName, null, stoppingToken);
|
||||
WorkerTelemetry.RecordIteration(processorName, true, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await stateReporter.CompletedAsync(processorName, exception, CancellationToken.None);
|
||||
WorkerTelemetry.RecordIteration(processorName, false, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static void InitializeSystem(IServiceProvider services, string reason) =>
|
||||
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
|
||||
}
|
||||
|
||||
internal sealed class TenantDomainWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
IOptions<WorkerOptions> workerOptions,
|
||||
ILogger<TenantDomainWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "tenant-domain-lifecycle",
|
||||
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)), workerOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<WorkerOptions> options,
|
||||
ILogger<SaasSubscriptionWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "saas-subscription-lifecycle", TimeSpan.FromSeconds(60), options.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<FeatureUsageReconciliationOptions> featureOptions,
|
||||
IOptions<WorkerOptions> workerOptions,
|
||||
ILogger<FeatureUsageWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "feature-usage-reconciliation",
|
||||
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)), workerOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobsWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<WorkerOptions> options,
|
||||
ILogger<BackgroundJobsWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "background-jobs", TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
private readonly int parallelism = options.Value.JobParallelism;
|
||||
private readonly int batchSize = options.Value.JobBatchSize;
|
||||
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workers = Enumerable.Range(0, parallelism)
|
||||
.Select(index => ProcessPartitionAsync(index, cancellationToken));
|
||||
return (await Task.WhenAll(workers)).Sum();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPartitionAsync(int index, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Background job lease worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync($"{workerId}:{index}", batchSize, includeImmediateJobs: true, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
7
Tiku.Worker/appsettings.Development.json
Normal file
7
Tiku.Worker/appsettings.Development.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Tiku.Worker/appsettings.json
Normal file
90
Tiku.Worker/appsettings.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console"
|
||||
}
|
||||
],
|
||||
"Properties": {
|
||||
"Application": "Tiku.Worker"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry": {
|
||||
"OtlpEndpoint": ""
|
||||
},
|
||||
"Worker": {
|
||||
"Enabled": true,
|
||||
"JobPollSeconds": 2,
|
||||
"JobParallelism": 4,
|
||||
"JobBatchSize": 5
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"PollSeconds": 60,
|
||||
"BatchSize": 50,
|
||||
"DnsJsonEndpoint": "https://cloudflare-dns.com/dns-query",
|
||||
"VerificationRecordPrefix": "_tiku-verification",
|
||||
"AllowedCnameTargets": [],
|
||||
"GatewayBaseUrl": null,
|
||||
"GatewayApiKey": null
|
||||
},
|
||||
"SaasSubscriptions": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"PastDueGraceDays": 7
|
||||
},
|
||||
"FeatureUsageReconciliation": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"IntervalMinutes": 60
|
||||
},
|
||||
"Storage": {
|
||||
"DefaultProvider": "aliyun_oss",
|
||||
"DefaultBucket": "tenant-assets",
|
||||
"PublicBaseUrl": "",
|
||||
"MaxUploadBytes": 524288000,
|
||||
"AllowedMimePrefixes": [
|
||||
"image/",
|
||||
"video/",
|
||||
"audio/"
|
||||
],
|
||||
"AllowedMimeTypes": [
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/gzip",
|
||||
"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": {
|
||||
"ClamAV": {
|
||||
"Host": "localhost",
|
||||
"Port": 3310,
|
||||
"TimeoutSeconds": 30,
|
||||
"ChunkBytes": 65536,
|
||||
"StreamMaxLength": 524288000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,14 @@
|
||||
| [本地开发与运行](quickstart.md) | PostgreSQL 初始化、启动 API、验证命令、常见问题 | 新开发者 |
|
||||
| [系统架构与业务边界](architecture/overview.md) | 项目依赖、运行时组件、当前业务模块和后台任务链路 | 开发与评审人员 |
|
||||
| [认证、授权与租户隔离](architecture/security-and-tenancy.md) | 登录、Session、JWT、Cookie/CSRF、Realm、RBAC、Capability、DataScope、租户隔离 | API 与安全开发者 |
|
||||
| [配置与后台任务](operations.md) | 环境配置、Production 启动门禁、Redis、Hosted Service、健康检查 | 开发与运维人员 |
|
||||
| [配置与后台任务](operations.md) | 环境配置、Production 启动门禁、Redis、Worker、ClamAV 和健康检查 | 开发与运维人员 |
|
||||
|
||||
## 权威来源
|
||||
|
||||
- API 契约:`Tiku.Api/Controllers`、请求/响应 DTO 和运行时 OpenAPI。
|
||||
- 数据模型:`Tiku.Domain`、`Tiku.Infrastructure/Persistence/Configurations` 和 EF Core Migration。
|
||||
- 认证授权:`Tiku.Api/Configuration`、`Tiku.Api/Middleware`、`Tiku.Application/Security`、`Tiku.Infrastructure/Security`。
|
||||
- 后台任务:`Tiku.Api/BackgroundProcessing`、`Tiku.Application/Jobs` 和 `Tiku.Infrastructure/Jobs`。
|
||||
- 后台任务:`Tiku.Worker`、`Tiku.Application/Jobs` 和 `Tiku.Infrastructure/Jobs`。
|
||||
- 外部服务:Application 接口与 Infrastructure 实现;运行时租户配置存储在 `TenantExternalProvider` 和 `TenantSecret`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
## 分层与依赖
|
||||
|
||||
```text
|
||||
+-------------------------+ +-----------------+
|
||||
| Tiku.Api | | Tiku.DbMigrator |
|
||||
| HTTP + Hosted Services | | Migrate + Seed |
|
||||
+------------+------------+ +--------+--------+
|
||||
| |
|
||||
+--------------+--------------+
|
||||
+------------------+ +------------------+ +-----------------+
|
||||
| Tiku.Api | | Tiku.Worker | | Tiku.DbMigrator |
|
||||
| HTTP API | | Background loops | | Migrate + Seed |
|
||||
+--------+---------+ +--------+---------+ +--------+--------+
|
||||
| | |
|
||||
+---------------------+---------------------+
|
||||
v
|
||||
+---------------------+
|
||||
| Tiku.Infrastructure |
|
||||
@@ -28,7 +28,7 @@
|
||||
- `Tiku.Domain` 保存领域实体、枚举和基础类型。除 Identity stores 抽象外,不依赖持久化或 Provider SDK。
|
||||
- `Tiku.Application` 定义用例契约、Provider 接口、安全上下文和业务目录,依赖 Domain。
|
||||
- `Tiku.Infrastructure` 实现 EF Core、PostgreSQL、Identity、外部 Provider 和后台任务,依赖 Application 与 Domain。
|
||||
- `Tiku.Api` 是唯一运行时入口,`Tiku.DbMigrator` 是部署时迁移和 seed 入口。
|
||||
- `Tiku.Api` 与 `Tiku.Worker` 是独立运行时入口,`Tiku.DbMigrator` 是部署时迁移和 seed 入口。
|
||||
|
||||
## 运行时组件
|
||||
|
||||
@@ -63,20 +63,20 @@ OpenAPI 和 Scalar 只在 Development 映射。平台管理端位于独立的 `T
|
||||
|
||||
API 不自动迁移数据库。
|
||||
|
||||
### API 后台处理
|
||||
### Worker 后台处理
|
||||
|
||||
`Tiku.Api` 在 `BackgroundProcessing:Enabled=true` 时注册四个 Hosted Service:
|
||||
`Tiku.Worker` 独立注册四个 Hosted Service,API 进程不注册后台循环:
|
||||
|
||||
| Hosted Service | 周期 | 当前职责 |
|
||||
| Worker | 周期 | 当前职责 |
|
||||
| --- | --- | --- |
|
||||
| `TenantDomainBackgroundService` | `TenantDomains:PollSeconds`,限制为 10~3600 秒 | 校验自定义域名 CNAME/TXT,调用网关 TLS 接口并失效租户缓存 |
|
||||
| `SaasSubscriptionBackgroundService` | 60 秒 | 处理到期、宽限期等 SaaS 订阅生命周期 |
|
||||
| `FeatureUsageBackgroundService` | `FeatureUsageReconciliation:IntervalMinutes`,限制为 1~1440 分钟 | 按真实业务数据校准租户 Feature 用量 |
|
||||
| `BackgroundJobsBackgroundService` | 默认 2 秒、4 个分区 | 使用租约处理 PostgreSQL 中的即时、延时和待重试任务 |
|
||||
| `TenantDomainWorker` | `TenantDomains:PollSeconds`,限制为 10~3600 秒 | 校验自定义域名 CNAME/TXT,调用网关 TLS 接口并失效租户缓存 |
|
||||
| `SaasSubscriptionWorker` | 60 秒 | 处理到期、宽限期等 SaaS 订阅生命周期 |
|
||||
| `FeatureUsageWorker` | `FeatureUsageReconciliation:IntervalMinutes`,限制为 1~1440 分钟 | 按真实业务数据校准租户 Feature 用量 |
|
||||
| `BackgroundJobsWorker` | 默认 2 秒、4 个分区 | 使用租约处理 PostgreSQL 中的即时、延时和待重试任务 |
|
||||
|
||||
后台任务当前支持 `content_import`、`content_export`、`statistics_aggregation`、`commerce_reconciliation` 和 `tenant_domain_recheck`。`asset_security_scan` 会明确失败,直到配置实际扫描 Provider;不能把它描述为已接通扫描服务。
|
||||
后台任务支持 `content_import`、`content_export`、`asset_security_scan`、`tenant_export`、`statistics_aggregation`、`commerce_reconciliation` 和 `tenant_domain_recheck`。安全扫描通过 ClamAV `INSTREAM` 协议流式处理对象;未通过扫描或扫描不可用时资源访问 fail-closed。
|
||||
|
||||
即时任务与 `RunAfter` 延时任务统一写入 `background_jobs`。Hosted Service 使用 `FOR UPDATE SKIP LOCKED` 认领任务,五分钟租约支持 API 重启后的恢复;当前生产部署按单 API 实例设计。
|
||||
即时任务与 `RunAfter` 延时任务统一写入 `background_jobs`。后台任务用 `FOR UPDATE SKIP LOCKED` 和五分钟租约协调;四个周期处理器使用 PostgreSQL advisory lock,允许部署多个 Worker 实例而不重复执行同一周期循环。
|
||||
|
||||
## 数据与持久化
|
||||
|
||||
|
||||
@@ -110,11 +110,11 @@ Development 默认平台 Host 是 `localhost` 和 `127.0.0.1`。Production 启
|
||||
|
||||
## System Scope 与后台处理
|
||||
|
||||
跨租户 Hosted Service、迁移、seed 和平台级后台操作必须通过 `ITenantContextInitializer.InitializeSystem` 或受审计的 `ITenantExecutionScope` 进入 System Scope,并提供明确原因。业务代码不得直接关闭 Query Filter。
|
||||
跨租户 Worker、迁移、seed 和平台级后台操作必须通过 `ITenantContextInitializer.InitializeSystem` 或受审计的 `ITenantExecutionScope` 进入 System Scope,并提供明确原因。业务代码不得直接关闭 Query Filter。
|
||||
|
||||
- Session、成员、租户和套餐状态始终从 PostgreSQL 重新校验。
|
||||
- 租户、套餐和 Feature 变更在数据库提交后直接失效当前 API 进程与 Redis 中的相关缓存。
|
||||
- 后台任务在业务事务提交后持久化到 PostgreSQL,Hosted Service 使用租约执行;延时和重试由 `RunAfter` 控制。
|
||||
- 后台任务在业务事务提交后持久化到 PostgreSQL,独立 Worker 使用租约执行;延时和重试由 `RunAfter` 控制。
|
||||
|
||||
## 安全配置门禁
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# 配置与后台任务
|
||||
|
||||
本文列出 API 和 DbMigrator 当前实际读取的配置。敏感值应通过环境变量、Secret Manager 或部署平台密钥注入,不能提交到仓库。
|
||||
本文列出 API、Worker 和 DbMigrator 当前实际读取的配置。敏感值应通过环境变量、Secret Manager 或部署平台密钥注入,不能提交到仓库。
|
||||
|
||||
## 进程与依赖
|
||||
|
||||
| 进程 | PostgreSQL | Redis | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Tiku.Api` | 必需 | Development 可选;Production 必需 | 提供 HTTP API、认证、缓存和 Hosted Service 后台处理 |
|
||||
| `Tiku.Api` | 必需 | Development 可选;Production 必需 | 提供 HTTP API、认证、授权和缓存 |
|
||||
| `Tiku.Worker` | 必需 | 不需要 | 承载周期任务、任务队列、租户导出和安全扫描 |
|
||||
| `Tiku.DbMigrator` | 必需 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导 |
|
||||
|
||||
Development 未配置 Redis 时,安全服务使用进程内/数据库防线。Production 不允许 Redis 降级;后台任务在所有环境统一使用 PostgreSQL。
|
||||
@@ -54,11 +55,11 @@ dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin
|
||||
|
||||
Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`。Redis 不是用户、Session、权限、套餐或用量的权威数据源。
|
||||
|
||||
## 后台处理配置
|
||||
## Worker 与后台处理配置
|
||||
|
||||
```json
|
||||
{
|
||||
"BackgroundProcessing": {
|
||||
"Worker": {
|
||||
"Enabled": true,
|
||||
"JobPollSeconds": 2,
|
||||
"JobParallelism": 4,
|
||||
@@ -89,7 +90,18 @@ Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`
|
||||
|
||||
域名只有在 `AllowedCnameTargets`、DNS JSON endpoint、Gateway URL 和 API key 配置完成后,才可能从 Pending/Failed 进入 Active。仅 DNS 验证成功不代表 TLS 已就绪。
|
||||
|
||||
后台任务状态和 `RunAfter` 存在 PostgreSQL。API Hosted Service 使用 `FOR UPDATE SKIP LOCKED`、五分钟租约和有限重试处理即时、延时及失败待重试任务。`BackgroundProcessing:Enabled=false` 会关闭全部四个后台循环,通常只用于测试或维护。
|
||||
后台任务状态和 `RunAfter` 存在 PostgreSQL。Worker 使用 `FOR UPDATE SKIP LOCKED`、五分钟租约和有限重试处理即时、延时及失败待重试任务;周期循环使用 PostgreSQL advisory lock 防止多实例重复执行。`Worker:Enabled=false` 会关闭全部四个后台循环,通常只用于测试或维护。
|
||||
|
||||
API 和 Worker 必须使用同一 PostgreSQL 数据库与一致的对象存储配置。迁移必须在两者启动前由 `Tiku.DbMigrator` 单独执行。
|
||||
|
||||
容器镜像从仓库根目录构建:
|
||||
|
||||
```bash
|
||||
docker build -f Tiku.Api/Dockerfile -t tiku-api .
|
||||
docker build -f Tiku.Worker/Dockerfile -t tiku-worker .
|
||||
```
|
||||
|
||||
API 和 Worker 应独立设置副本数与资源限制。先完成 Migration,再启动 Worker,最后开放 API 流量;不要在容器入口自动执行 Migration。
|
||||
|
||||
## 安全与网络配置
|
||||
|
||||
@@ -120,21 +132,53 @@ Production 启动至少需要核对:
|
||||
|
||||
## 对象存储与外部 Provider
|
||||
|
||||
对象存储读取 `Storage` / `Storage:AliyunOss`,也支持 `STORAGE_*` 与 `ALIYUN_OSS_*` 环境变量。当前默认实现是阿里云 OSS,并强制租户 key 前缀、上传大小和 MIME allowlist。
|
||||
对象存储读取 `Storage` / `Storage:AliyunOss`,API 与 Worker 都支持 `STORAGE_*` 与 `ALIYUN_OSS_*` 环境变量。当前默认实现是阿里云 OSS,并强制租户 key 前缀、上传大小和 MIME allowlist。租户导出使用 `application/gzip`,该类型不能从 allowlist 删除。
|
||||
|
||||
## ClamAV 资源安全扫描
|
||||
|
||||
API 和 Worker 都读取 `Security:ClamAV`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"ClamAV": {
|
||||
"Host": "clamav",
|
||||
"Port": 3310,
|
||||
"TimeoutSeconds": 30,
|
||||
"ChunkBytes": 65536,
|
||||
"StreamMaxLength": 524288000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Worker 使用 ClamAV `INSTREAM` 协议,不在本地落盘待扫描对象。启动校验要求 `StreamMaxLength >= Storage:MaxUploadBytes`;同时必须把 clamd 自身的 `StreamMaxLength` 配到相同或更高值。扫描结果为 FOUND 时资源标记为 Failed 并记录病毒签名;ClamAV 超时或不可用时任务保留 Pending 并退避重试。只有 `Passed` 或系统明确标记为可信的 `NotRequired` 资源可以签发访问地址。
|
||||
|
||||
身份、短信、支付、通知和 AI 的租户配置由业务后台写入 `TenantExternalProvider`;敏感值写入加密的 `TenantSecret`。全局默认配置不能绕过租户 Provider 状态和 Secret 边界。
|
||||
|
||||
## 健康检查与观测
|
||||
|
||||
- `GET /api/health`:轻量 liveness,只说明 API 进程可响应。
|
||||
- `GET /api/health/ready`:检查 PostgreSQL 和已配置 Redis;依赖未就绪时返回 503。
|
||||
- `GET /api/health/ready`:检查 PostgreSQL 和已配置 Redis;依赖未就绪时返回 503,匿名响应只包含总体状态和检查时间。
|
||||
- `GET /api/platform-admin/operations/health`:需要 `platform:operations:view`,返回 PostgreSQL、Redis、Worker heartbeat、ClamAV 和对象存储配置状态。
|
||||
- `GET /api/platform-admin/operations/workers`:查询 Worker 心跳、周期循环和 stale 状态。
|
||||
- `GET /api/platform-admin/operations/job-metrics`:查询队列状态、最老 Pending 任务与过期租约。
|
||||
- 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client 和数据库观测数据。
|
||||
- Serilog 输出结构化请求日志;数据库性能拦截器记录慢查询指标。
|
||||
|
||||
Readiness 为绿色不等于认证授权、跨租户隔离或后台任务恢复演练已通过,发布仍需执行对应集成测试。
|
||||
|
||||
## 租户归档与导出发布门禁
|
||||
|
||||
- 租户归档是逻辑归档,禁止硬删除。
|
||||
- 归档前必须存在 24 小时内成功完成的租户导出,且不能有 Processing 后台任务。
|
||||
- 导出包是 `tar.gz`,包含 manifest、租户、成员、域名和资源元数据;明确排除密码哈希、令牌、密钥明文、Data Protection keys 和全局平台数据。
|
||||
- 归档会撤销该租户授权域 Session、禁用域名并失效运行时缓存;恢复后租户为 `Suspended`,域名为 `Pending`,必须重新审核后再激活。
|
||||
- Owner 转移目标必须是已有 Active 成员,并在同一事务内同步成员角色和后台角色绑定。
|
||||
- 发布前至少演练一次导出可下载、归档阻断条件、归档、恢复和 Owner 转移。
|
||||
|
||||
## 从 RabbitMQ 版本切换
|
||||
|
||||
移除消息表的 Migration 与旧 API/Worker 不兼容。发布时使用维护窗口:停止旧 API 和 Worker,确认 RabbitMQ Consumer 已退出并备份 PostgreSQL,运行 `Tiku.DbMigrator`,再部署新 API。未消费的后台任务消息可丢弃,因为对应任务记录已经写入 `background_jobs`;安全消息不保存授权真相。
|
||||
移除消息表的 Migration 与旧 API/Worker 不兼容。发布时使用维护窗口:停止旧 API 和 Worker,确认 RabbitMQ Consumer 已退出并备份 PostgreSQL,运行 `Tiku.DbMigrator`,再部署新 API 与新 Worker。未消费的后台任务消息可丢弃,因为对应任务记录已经写入 `background_jobs`;安全消息不保存授权真相。
|
||||
|
||||
切换时不强制重置 `processing` 任务。旧租约最多五分钟后由新 API 接管。回滚需要先停止新 API,执行 Migration Down 重建空 inbox/outbox 表,再恢复 RabbitMQ 配置和旧 API/Worker;历史消息不会恢复。
|
||||
切换时不强制重置 `processing` 任务。旧租约最多五分钟后由新 Worker 接管。回滚需要先停止新 API 与 Worker,执行 Migration Down 重建空 inbox/outbox 表,再恢复 RabbitMQ 配置和旧 API/Worker;历史消息不会恢复。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
可选:
|
||||
|
||||
- Redis 7;
|
||||
- ClamAV(验证资源安全扫描时需要)。
|
||||
|
||||
```bash
|
||||
dotnet --version
|
||||
@@ -98,15 +99,17 @@ export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
|
||||
|
||||
Production 必须配置 Redis;PostgreSQL 仍是用户、Session、权限、套餐和用量的权威数据源。
|
||||
|
||||
## 7. 后台处理
|
||||
## 7. 启动 Worker 与 ClamAV
|
||||
|
||||
API 默认在同一进程启动域名、订阅、用量和后台任务 Hosted Service。需要临时关闭时配置:
|
||||
API 不处理后台循环。另开终端启动 Worker:
|
||||
|
||||
```bash
|
||||
export BackgroundProcessing__Enabled=false
|
||||
dotnet run --project Tiku.Worker
|
||||
```
|
||||
|
||||
后台任务状态、租约、重试和 `RunAfter` 存在 PostgreSQL。域名 DNS/TLS 流程只有在 `TenantDomains` 的 CNAME target 和 Gateway 配置完整后才能激活自定义域名。
|
||||
`Worker__Enabled=false` 仅用于测试或维护。后台任务状态、租约、重试和 `RunAfter` 存在 PostgreSQL;多个 Worker 通过 advisory lock 和任务租约协调。域名 DNS/TLS 流程只有在 `TenantDomains` 的 CNAME target 和 Gateway 配置完整后才能激活自定义域名。
|
||||
|
||||
上传确认会创建 `asset_security_scan` 任务。Worker 通过 TCP 3310 连接 ClamAV,且 ClamAV `StreamMaxLength` 必须不小于 `Storage:MaxUploadBytes`(当前默认均为 500 MiB)。本地可以使用容器启动 ClamAV,并确保该限制已配置;ClamAV 不可用时任务会重试,资源保持不可访问。
|
||||
|
||||
## 8. 开发验证
|
||||
|
||||
@@ -146,7 +149,7 @@ psql -h 127.0.0.1 -U <数据库用户> -d postgres -c 'select current_user;'
|
||||
|
||||
### Readiness 返回 503
|
||||
|
||||
检查响应中的 `database` 和 `redis.ready`。配置了 Redis 连接串但服务未启动时,readiness 会返回 503。
|
||||
匿名 readiness 只返回总体 `status` 与 `checkedAt`。配置了 Redis 连接串但服务未启动时会返回 503;依赖细节需要使用具有 `platform:operations:view` 权限的平台账号访问 `/api/platform-admin/operations/health`。
|
||||
|
||||
### API 出现 HTTPS 重定向警告
|
||||
|
||||
|
||||
Reference in New Issue
Block a user