feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.gitignore
.codegraph
.idea
.vscode
**/.DS_Store
**/bin
**/obj
**/TestResults
**/node_modules
Tiku.PlatformAdmin.Web
tools/performance/results

View File

@@ -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 后台任务。
![TIKU Backend 技术架构图](docs/assets/tiku-backend-architecture.svg)
@@ -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 可不配置 RedisProduction 缺少 Redis 时 API 会拒绝启动。
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code``tenantCode` 指定租户。
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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.");
}

View File

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

View File

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

View File

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

View File

@@ -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("查询平台审计日志")]

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

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

View File

@@ -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("查询租户审计日志")]

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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.");

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.")
};

View File

@@ -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."));
}

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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")]

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

View File

@@ -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()

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
}
}
}

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

View File

@@ -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`
## 维护规则

View File

@@ -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 ServiceAPI 进程不注册后台循环
| Hosted Service | 周期 | 当前职责 |
| Worker | 周期 | 当前职责 |
| --- | --- | --- |
| `TenantDomainBackgroundService` | `TenantDomains:PollSeconds`,限制为 103600 秒 | 校验自定义域名 CNAME/TXT调用网关 TLS 接口并失效租户缓存 |
| `SaasSubscriptionBackgroundService` | 60 秒 | 处理到期、宽限期等 SaaS 订阅生命周期 |
| `FeatureUsageBackgroundService` | `FeatureUsageReconciliation:IntervalMinutes`,限制为 11440 分钟 | 按真实业务数据校准租户 Feature 用量 |
| `BackgroundJobsBackgroundService` | 默认 2 秒、4 个分区 | 使用租约处理 PostgreSQL 中的即时、延时和待重试任务 |
| `TenantDomainWorker` | `TenantDomains:PollSeconds`,限制为 103600 秒 | 校验自定义域名 CNAME/TXT调用网关 TLS 接口并失效租户缓存 |
| `SaasSubscriptionWorker` | 60 秒 | 处理到期、宽限期等 SaaS 订阅生命周期 |
| `FeatureUsageWorker` | `FeatureUsageReconciliation:IntervalMinutes`,限制为 11440 分钟 | 按真实业务数据校准租户 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 实例而不重复执行同一周期循环
## 数据与持久化

View File

@@ -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 中的相关缓存。
- 后台任务在业务事务提交后持久化到 PostgreSQLHosted Service 使用租约执行;延时和重试由 `RunAfter` 控制。
- 后台任务在业务事务提交后持久化到 PostgreSQL独立 Worker 使用租约执行;延时和重试由 `RunAfter` 控制。
## 安全配置门禁

View File

@@ -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历史消息不会恢复。

View File

@@ -13,6 +13,7 @@
可选:
- Redis 7
- ClamAV验证资源安全扫描时需要
```bash
dotnet --version
@@ -98,15 +99,17 @@ export ConnectionStrings__Redis='localhost:6379,abortConnect=false'
Production 必须配置 RedisPostgreSQL 仍是用户、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 重定向警告