diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..fc02a0c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.git
+.gitignore
+.codegraph
+.idea
+.vscode
+**/.DS_Store
+**/bin
+**/obj
+**/TestResults
+**/node_modules
+Tiku.PlatformAdmin.Web
+tools/performance/results
diff --git a/README.md b/README.md
index e7ea9b2..e1d0c47 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TIKU Backend
-TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据,由同一个 API 进程提供平台端、租户端和学生端接口并处理后台任务。
+TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据。`Tiku.Api` 提供平台端、租户端和学生端接口,`Tiku.Worker` 独立处理周期任务与 PostgreSQL 后台任务。

@@ -18,7 +18,8 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core
## 解决方案结构
```text
-Tiku.Api HTTP API、中间件、认证授权、OpenAPI/Scalar 和 Hosted Service
+Tiku.Api HTTP API、中间件、认证授权和 OpenAPI/Scalar
+Tiku.Worker 域名、订阅、用量校准、导出和安全扫描等后台处理
Tiku.Application 用例契约、应用服务接口和安全上下文
Tiku.Domain 领域实体、枚举和值对象
Tiku.Infrastructure EF Core、PostgreSQL、认证、后台任务和外部服务实现
@@ -27,7 +28,7 @@ Tiku.UnitTests 单元测试
Tiku.IntegrationTests API、授权、EF 模型、迁移和真实 PostgreSQL 测试
```
-依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 是唯一运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
+依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 与 `Worker` 是彼此独立的运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
## 快速启动
@@ -39,6 +40,7 @@ dotnet restore TIKU-BACKEND.slnx
dotnet build TIKU-BACKEND.slnx --no-restore
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator
dotnet run --project Tiku.Api
+dotnet run --project Tiku.Worker
```
Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临时密码只在 DbMigrator 首次运行的终端输出。完整步骤见[本地开发与运行](docs/quickstart.md)。
@@ -54,6 +56,8 @@ Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临
## 运行时边界
- API 不自动执行数据库迁移;部署和本地初始化都使用 `Tiku.DbMigrator`。
+- API 不运行后台循环;生产环境必须独立部署至少一个 `Tiku.Worker` 实例。
+- 多 Worker 实例通过 PostgreSQL advisory lock、任务租约和 `FOR UPDATE SKIP LOCKED` 协调。
- Development 可不配置 Redis;Production 缺少 Redis 时 API 会拒绝启动。
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code` 或 `tenantCode` 指定租户。
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。
diff --git a/TIKU-BACKEND.slnx b/TIKU-BACKEND.slnx
index 957777a..1071a5c 100644
--- a/TIKU-BACKEND.slnx
+++ b/TIKU-BACKEND.slnx
@@ -5,6 +5,7 @@
+
diff --git a/Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs b/Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
deleted file mode 100644
index 80a38f8..0000000
--- a/Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
+++ /dev/null
@@ -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 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().InitializeSystem(null, reason);
-}
-
-internal sealed class TenantDomainBackgroundService(
- IServiceScopeFactory scopeFactory,
- IOptions domainOptions,
- IOptions backgroundOptions,
- ILogger logger)
- : PeriodicBackgroundService(
- logger,
- TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)),
- backgroundOptions.Value.Enabled)
-{
- protected override async Task 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()
- .ProcessPendingAsync(cancellationToken);
- }
-}
-
-internal sealed class SaasSubscriptionBackgroundService(
- IServiceScopeFactory scopeFactory,
- IOptions options,
- ILogger logger)
- : PeriodicBackgroundService(logger, TimeSpan.FromSeconds(60), options.Value.Enabled)
-{
- protected override async Task ProcessAsync(CancellationToken cancellationToken)
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle background service");
- return await scope.ServiceProvider.GetRequiredService()
- .ProcessDueAsync(cancellationToken: cancellationToken);
- }
-}
-
-internal sealed class FeatureUsageBackgroundService(
- IServiceScopeFactory scopeFactory,
- IOptions featureOptions,
- IOptions backgroundOptions,
- ILogger logger)
- : PeriodicBackgroundService(
- logger,
- TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)),
- backgroundOptions.Value.Enabled)
-{
- protected override async Task ProcessAsync(CancellationToken cancellationToken)
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation background service");
- return await scope.ServiceProvider.GetRequiredService()
- .ProcessDueAsync(cancellationToken);
- }
-}
-
-internal sealed class BackgroundJobsBackgroundService(
- IServiceScopeFactory scopeFactory,
- IOptions options,
- ILogger 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 ProcessAsync(CancellationToken cancellationToken)
- {
- var workers = Enumerable.Range(0, parallelism)
- .Select(index => ProcessPartitionAsync(index, cancellationToken));
- return (await Task.WhenAll(workers)).Sum();
- }
-
- private async Task ProcessPartitionAsync(int index, CancellationToken cancellationToken)
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- InitializeSystem(scope.ServiceProvider, "Background job lease service");
- return await scope.ServiceProvider.GetRequiredService()
- .ProcessPendingAsync(
- $"{workerId}:{index}",
- batchSize,
- includeImmediateJobs: true,
- cancellationToken: cancellationToken);
- }
-}
diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs
index 127a9b2..cf96bd2 100644
--- a/Tiku.Api/Configuration/DependencyInjection.cs
+++ b/Tiku.Api/Configuration/DependencyInjection.cs
@@ -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(options => options.Level = CompressionLevel.Fastest);
builder.Services.Configure(options => options.Level = CompressionLevel.Fastest);
- builder.Services.AddOptions()
- .Bind(builder.Configuration.GetSection(BackgroundProcessingOptions.SectionName))
- .Validate(BackgroundProcessingOptions.BeValid, "Background processing settings are invalid.")
- .ValidateOnStart();
- builder.Services.AddOptions()
- .Bind(builder.Configuration.GetSection("TenantDomains"));
- builder.Services.AddOptions()
- .Bind(builder.Configuration.GetSection("SaasSubscriptions"));
- builder.Services.AddOptions()
- .Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
- builder.Services.AddHostedService();
- builder.Services.AddHostedService();
- builder.Services.AddHostedService();
- builder.Services.AddHostedService();
-
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
builder.Services.AddApiAuthenticationAndAuthorization(builder.Configuration, builder.Environment);
diff --git a/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs b/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs
index 8ff4a25..e954624 100644
--- a/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs
+++ b/Tiku.Api/Configuration/ExternalServiceOptionsExtensions.cs
@@ -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()
+ .Validate(
+ options => !environment.IsProduction() ||
+ options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
+ "Production managed storage must use the configured Aliyun OSS provider.")
+ .ValidateOnStart();
+ services.AddOptions()
+ .Validate>(
+ (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()
+ .Bind(configuration.GetSection(ClamAvOptions.SectionName))
+ .Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
+ .Validate>(
+ (clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes,
+ "ClamAV StreamMaxLength must be greater than or equal to the storage max upload size.")
+ .ValidateOnStart();
services.AddOptions()
.Bind(configuration.GetSection(TenantSecretEncryptionOptions.SectionName))
diff --git a/Tiku.Api/Configuration/ObservabilityExtensions.cs b/Tiku.Api/Configuration/ObservabilityExtensions.cs
index 1f4b59e..57d3f62 100644
--- a/Tiku.Api/Configuration/ObservabilityExtensions.cs
+++ b/Tiku.Api/Configuration/ObservabilityExtensions.cs
@@ -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;
diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs
index 42525f2..81bd621 100644
--- a/Tiku.Api/Contracts/AuthDtos.cs
+++ b/Tiku.Api/Contracts/AuthDtos.cs
@@ -268,3 +268,71 @@ public sealed class RequiredPasswordChangeDto
[StringLength(128, MinimumLength = 8)]
public string NewPassword { get; set; } = string.Empty;
}
+
+///
+/// 请求租户短信密码重置验证码。
+///
+public sealed class PasswordResetSmsSendDto
+{
+ /// 租户编码;使用租户自定义域名时可省略。
+ [StringLength(100)]
+ public string? TenantCode { get; set; }
+
+ /// 绑定到账号的手机号。
+ [Required, StringLength(32)]
+ public string Phone { get; set; } = string.Empty;
+
+ /// 客户端设备标识,用于安全频控。
+ [StringLength(256)]
+ public string? DeviceId { get; set; }
+}
+
+///
+/// 使用短信验证码重置租户账号密码。
+///
+public sealed class PasswordResetDto
+{
+ /// 租户编码;使用租户自定义域名时可省略。
+ [StringLength(100)]
+ public string? TenantCode { get; set; }
+
+ /// 绑定到账号的手机号。
+ [Required, StringLength(32)]
+ public string Phone { get; set; } = string.Empty;
+
+ /// 短信验证码。
+ [Required, StringLength(12, MinimumLength = 4)]
+ public string Code { get; set; } = string.Empty;
+
+ /// 符合当前密码策略的新密码。
+ [Required, StringLength(128, MinimumLength = 8)]
+ public string NewPassword { get; set; } = string.Empty;
+}
+
+///
+/// 已登录用户修改密码。
+///
+public sealed class AuthenticatedPasswordChangeDto
+{
+ /// 当前密码。
+ [Required, StringLength(128, MinimumLength = 1)]
+ public string CurrentPassword { get; set; } = string.Empty;
+
+ /// 符合当前密码策略的新密码。
+ [Required, StringLength(128, MinimumLength = 8)]
+ public string NewPassword { get; set; } = string.Empty;
+}
+
+///
+/// 管理员为用户设置一次性临时密码。
+///
+public sealed class AdministrativePasswordResetDto
+{
+ /// 符合当前密码策略的临时密码。
+ [Required, StringLength(128, MinimumLength = 12)]
+ public string TemporaryPassword { get; set; } = string.Empty;
+
+ /// 审计原因。
+ [Required, StringLength(1000, MinimumLength = 3)]
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/Tiku.Api/Contracts/BackgroundJobDtos.cs b/Tiku.Api/Contracts/BackgroundJobDtos.cs
index 7c0c8d9..57864e8 100644
--- a/Tiku.Api/Contracts/BackgroundJobDtos.cs
+++ b/Tiku.Api/Contracts/BackgroundJobDtos.cs
@@ -24,6 +24,8 @@ public sealed class CreateBackgroundJobDto
/// 最大重试次数。
///
public int MaxRetries { get; set; } = 3;
+ /// 同租户同任务类型内的可选幂等键。
+ 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);
}
}
+
+/// 后台任务取消请求。
+public sealed class CancelBackgroundJobDto
+{
+ /// 取消原因。
+ [System.ComponentModel.DataAnnotations.Required]
+ [System.ComponentModel.DataAnnotations.StringLength(1000, MinimumLength = 3)]
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/Tiku.Api/Contracts/TenantLifecycleDtos.cs b/Tiku.Api/Contracts/TenantLifecycleDtos.cs
new file mode 100644
index 0000000..d9a3503
--- /dev/null
+++ b/Tiku.Api/Contracts/TenantLifecycleDtos.cs
@@ -0,0 +1,23 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Tiku.Api.Contracts;
+
+/// 租户生命周期变更原因。
+public sealed class TenantLifecycleReasonDto
+{
+ /// 审计原因。
+ [Required, StringLength(1000, MinimumLength = 3)]
+ public string Reason { get; set; } = string.Empty;
+}
+
+/// 租户所有者转移请求。
+public sealed class TenantOwnerTransferDto
+{
+ /// 新的所有者用户 ID;必须是现有活跃成员。
+ [Required]
+ public Guid TargetUserId { get; set; }
+
+ /// 审计原因。
+ [Required, StringLength(1000, MinimumLength = 3)]
+ public string Reason { get; set; } = string.Empty;
+}
diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs
index e61742f..4560bf5 100644
--- a/Tiku.Api/Controllers/AuthController.cs
+++ b/Tiku.Api/Controllers/AuthController.cs
@@ -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(StatusCodes.Status202Accepted)]
+ public async Task> 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 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> 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))
diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs
index 3ba048a..46320f5 100644
--- a/Tiku.Api/Controllers/BackgroundJobsController.cs
+++ b/Tiku.Api/Controllers/BackgroundJobsController.cs
@@ -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> 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> 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> 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.");
}
diff --git a/Tiku.Api/Controllers/BrowserAuthController.cs b/Tiku.Api/Controllers/BrowserAuthController.cs
index 85a2245..b0a707b 100644
--- a/Tiku.Api/Controllers/BrowserAuthController.cs
+++ b/Tiku.Api/Controllers/BrowserAuthController.cs
@@ -171,6 +171,79 @@ public sealed class BrowserAuthController(
return NoContent();
}
+ [AllowAnonymous]
+ [EnableRateLimiting(AuthRateLimitPolicies.Sms)]
+ [HttpPost("password/reset/sms/send")]
+ [EndpointSummary("发送浏览器密码重置短信验证码")]
+ public async Task> 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 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> 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)
diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs
index 96470be..42c0465 100644
--- a/Tiku.Api/Controllers/HealthController.cs
+++ b/Tiku.Api/Controllers/HealthController.cs
@@ -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);
}
}
diff --git a/Tiku.Api/Controllers/MeController.cs b/Tiku.Api/Controllers/MeController.cs
index b5f540b..2604d44 100644
--- a/Tiku.Api/Controllers/MeController.cs
+++ b/Tiku.Api/Controllers/MeController.cs
@@ -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>(StatusCodes.Status200OK)]
+ public async Task>> 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 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();
+ }
}
///
diff --git a/Tiku.Api/Controllers/PlatformAdminController.cs b/Tiku.Api/Controllers/PlatformAdminController.cs
index 58448aa..5028a85 100644
--- a/Tiku.Api/Controllers/PlatformAdminController.cs
+++ b/Tiku.Api/Controllers/PlatformAdminController.cs
@@ -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 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("查询平台审计日志")]
diff --git a/Tiku.Api/Controllers/PlatformOperationsController.cs b/Tiku.Api/Controllers/PlatformOperationsController.cs
new file mode 100644
index 0000000..5c34a35
--- /dev/null
+++ b/Tiku.Api/Controllers/PlatformOperationsController.cs
@@ -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) : ControllerBase
+{
+ [HttpGet("health")]
+ [EndpointSummary("查询受保护的依赖深度健康状态")]
+ public async Task> 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> 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> 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>> 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> 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> 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> 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.");
+}
diff --git a/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs b/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs
new file mode 100644
index 0000000..e7cb414
--- /dev/null
+++ b/Tiku.Api/Controllers/PlatformTenantLifecycleController.cs
@@ -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> Preview(Guid tenantId, CancellationToken cancellationToken) =>
+ Ok(await lifecycleService.PreviewArchiveAsync(tenantId, cancellationToken));
+
+ [HttpPost("exports")]
+ [EndpointSummary("创建租户数据导出")]
+ public async Task> CreateExport(
+ Guid tenantId,
+ CancellationToken cancellationToken) =>
+ Accepted(await lifecycleService.CreateExportAsync(tenantId, ResolveUserId(), cancellationToken));
+
+ [HttpGet("exports/{operationId:guid}")]
+ [EndpointSummary("查询租户数据导出状态")]
+ public async Task> 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> DownloadExport(
+ Guid tenantId,
+ Guid operationId,
+ CancellationToken cancellationToken) =>
+ Ok(await lifecycleService.SignExportDownloadAsync(tenantId, operationId, cancellationToken));
+
+ [HttpPost("archive")]
+ [EndpointSummary("逻辑归档租户")]
+ public async Task> Archive(
+ Guid tenantId,
+ TenantLifecycleReasonDto request,
+ CancellationToken cancellationToken) =>
+ Ok(await lifecycleService.ArchiveAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
+
+ [HttpPost("restore")]
+ [EndpointSummary("恢复租户到暂停状态")]
+ public async Task> Restore(
+ Guid tenantId,
+ TenantLifecycleReasonDto request,
+ CancellationToken cancellationToken) =>
+ Ok(await lifecycleService.RestoreAsync(tenantId, ResolveUserId(), request.Reason, cancellationToken));
+
+ [HttpPost("owner-transfer")]
+ [EndpointSummary("转移租户所有者")]
+ public async Task> 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.");
+}
diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs
index f28759f..51169c1 100644
--- a/Tiku.Api/Controllers/TenantAdminDirectController.cs
+++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs
@@ -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 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("查询租户审计日志")]
diff --git a/Tiku.Api/Dockerfile b/Tiku.Api/Dockerfile
index 6b0b003..945e8c0 100644
--- a/Tiku.Api/Dockerfile
+++ b/Tiku.Api/Dockerfile
@@ -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"]
diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
index 49a15e9..3a5e086 100644
--- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
+++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs
@@ -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,
diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json
index 439d2cf..0e3e477 100644
--- a/Tiku.Api/appsettings.json
+++ b/Tiku.Api/appsettings.json
@@ -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": "",
diff --git a/Tiku.Application/Assets/IAssetSecurityScanner.cs b/Tiku.Application/Assets/IAssetSecurityScanner.cs
new file mode 100644
index 0000000..3eed0c2
--- /dev/null
+++ b/Tiku.Application/Assets/IAssetSecurityScanner.cs
@@ -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 ScanAsync(
+ Stream content,
+ long? declaredLength,
+ CancellationToken cancellationToken = default);
+
+ Task CheckHealthAsync(CancellationToken cancellationToken = default);
+}
+
+public sealed class AssetSecurityScannerException(string code, string message, Exception? innerException = null)
+ : Exception(message, innerException)
+{
+ public string Code { get; } = code;
+}
diff --git a/Tiku.Application/Auth/AuthContracts.cs b/Tiku.Application/Auth/AuthContracts.cs
index ca04d40..7b91a67 100644
--- a/Tiku.Application/Auth/AuthContracts.cs
+++ b/Tiku.Application/Auth/AuthContracts.cs
@@ -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);
diff --git a/Tiku.Application/Auth/AuthExceptions.cs b/Tiku.Application/Auth/AuthExceptions.cs
index 77ed146..71717df 100644
--- a/Tiku.Application/Auth/AuthExceptions.cs
+++ b/Tiku.Application/Auth/AuthExceptions.cs
@@ -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.");
diff --git a/Tiku.Application/Auth/IAuthAdministrationService.cs b/Tiku.Application/Auth/IAuthAdministrationService.cs
new file mode 100644
index 0000000..488db7c
--- /dev/null
+++ b/Tiku.Application/Auth/IAuthAdministrationService.cs
@@ -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);
+}
diff --git a/Tiku.Application/Auth/IAuthService.cs b/Tiku.Application/Auth/IAuthService.cs
index 423d169..e0cd437 100644
--- a/Tiku.Application/Auth/IAuthService.cs
+++ b/Tiku.Application/Auth/IAuthService.cs
@@ -31,4 +31,16 @@ public interface IAuthService
Task ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default);
+
+ Task RequestPasswordResetAsync(
+ PasswordResetCodeRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task ResetPasswordAsync(
+ PasswordResetRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task ChangePasswordAsync(
+ AuthenticatedPasswordChangeRequest request,
+ CancellationToken cancellationToken = default);
}
diff --git a/Tiku.Application/Auth/IAuthSessionStore.cs b/Tiku.Application/Auth/IAuthSessionStore.cs
index 23b94e7..14b3690 100644
--- a/Tiku.Application/Auth/IAuthSessionStore.cs
+++ b/Tiku.Application/Auth/IAuthSessionStore.cs
@@ -25,9 +25,25 @@ public interface IAuthSessionStore
Guid? tenantId,
CancellationToken cancellationToken = default);
+ Task 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> ListActiveAsync(
+ Guid userId,
+ Guid currentSessionId,
+ CancellationToken cancellationToken = default);
+
+ Task RevokeOwnedFamilyAsync(
+ Guid userId,
+ Guid currentSessionId,
+ Guid sessionFamilyId,
+ CancellationToken cancellationToken = default);
}
public sealed record AuthSessionIssueRequest(
diff --git a/Tiku.Application/Jobs/BackgroundJobModels.cs b/Tiku.Application/Jobs/BackgroundJobModels.cs
index 0eda3c4..4e3c187 100644
--- a/Tiku.Application/Jobs/BackgroundJobModels.cs
+++ b/Tiku.Application/Jobs/BackgroundJobModels.cs
@@ -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 GetAsync(
+ Guid jobId,
+ Guid? tenantId,
+ CancellationToken cancellationToken = default);
+
+ Task> ListPlatformAsync(
+ Guid? tenantId = null,
+ string? jobType = null,
+ BackgroundJobStatus? status = null,
+ int limit = 100,
+ CancellationToken cancellationToken = default);
+
+ Task RequestCancellationAsync(
+ Guid jobId,
+ Guid? tenantId,
+ Guid actorUserId,
+ string reason,
+ CancellationToken cancellationToken = default);
+
+ Task RetryAsync(
+ Guid jobId,
+ Guid? tenantId,
+ Guid actorUserId,
+ CancellationToken cancellationToken = default);
}
diff --git a/Tiku.Application/Security/BackendPermissions.cs b/Tiku.Application/Security/BackendPermissions.cs
index 73b57ad..85ac0a9 100644
--- a/Tiku.Application/Security/BackendPermissions.cs
+++ b/Tiku.Application/Security/BackendPermissions.cs
@@ -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 Tenant = new HashSet(StringComparer.Ordinal)
{
@@ -73,7 +75,9 @@ public static class BackendPermissions
PlatformSmsRead,
PlatformSmsWrite,
PlatformPaymentRead,
- PlatformPaymentWrite
+ PlatformPaymentWrite,
+ PlatformOperationsView,
+ PlatformOperationsManage
};
public static void EnsureTenant(string permissionCode)
diff --git a/Tiku.Application/Security/SaasFeatureCatalog.cs b/Tiku.Application/Security/SaasFeatureCatalog.cs
index 24edad9..95666e1 100644
--- a/Tiku.Application/Security/SaasFeatureCatalog.cs
+++ b/Tiku.Application/Security/SaasFeatureCatalog.cs
@@ -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.")
};
diff --git a/Tiku.Application/Storage/IObjectStorageService.cs b/Tiku.Application/Storage/IObjectStorageService.cs
index fcee771..59fbb71 100644
--- a/Tiku.Application/Storage/IObjectStorageService.cs
+++ b/Tiku.Application/Storage/IObjectStorageService.cs
@@ -26,4 +26,9 @@ public interface IObjectStorageService
Task HeadObjectAsync(
ObjectStorageHeadRequest request,
CancellationToken cancellationToken = default);
+
+ Task OpenReadAsync(
+ ObjectStorageReadRequest request,
+ CancellationToken cancellationToken = default) =>
+ Task.FromException(new NotSupportedException("Object storage read streaming is not configured."));
}
diff --git a/Tiku.Application/Storage/ObjectStorageContracts.cs b/Tiku.Application/Storage/ObjectStorageContracts.cs
index a27744f..687b1e1 100644
--- a/Tiku.Application/Storage/ObjectStorageContracts.cs
+++ b/Tiku.Application/Storage/ObjectStorageContracts.cs
@@ -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,
diff --git a/Tiku.Application/Tenancy/TenantLifecycleModels.cs b/Tiku.Application/Tenancy/TenantLifecycleModels.cs
new file mode 100644
index 0000000..bc0ac28
--- /dev/null
+++ b/Tiku.Application/Tenancy/TenantLifecycleModels.cs
@@ -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 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 PreviewArchiveAsync(Guid tenantId, CancellationToken cancellationToken = default);
+ Task CreateExportAsync(Guid tenantId, Guid actorUserId, CancellationToken cancellationToken = default);
+ Task GetOperationAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default);
+ Task SignExportDownloadAsync(Guid tenantId, Guid operationId, CancellationToken cancellationToken = default);
+ Task ArchiveAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default);
+ Task RestoreAsync(Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default);
+ Task 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;
+}
diff --git a/Tiku.Domain/Operations/OperationsEntities.cs b/Tiku.Domain/Operations/OperationsEntities.cs
index d53fc08..7b91d35 100644
--- a/Tiku.Domain/Operations/OperationsEntities.cs
+++ b/Tiku.Domain/Operations/OperationsEntities.cs
@@ -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 }
diff --git a/Tiku.Infrastructure/Assets/AssetAccessService.cs b/Tiku.Infrastructure/Assets/AssetAccessService.cs
index 99395f2..07d5a42 100644
--- a/Tiku.Infrastructure/Assets/AssetAccessService.cs
+++ b/Tiku.Infrastructure/Assets/AssetAccessService.cs
@@ -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");
}
diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs
index 8d0ccd4..1e0c761 100644
--- a/Tiku.Infrastructure/Assets/AssetManagementService.cs
+++ b/Tiku.Infrastructure/Assets/AssetManagementService.cs
@@ -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);
}
diff --git a/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs b/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs
new file mode 100644
index 0000000..a23030b
--- /dev/null
+++ b/Tiku.Infrastructure/Assets/ClamAvAssetSecurityScanner.cs
@@ -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 options) : IAssetSecurityScanner
+{
+ private readonly ClamAvOptions settings = options.Value;
+
+ public async Task 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 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 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();
+ }
+}
diff --git a/Tiku.Infrastructure/Auth/AuthAdministrationService.cs b/Tiku.Infrastructure/Auth/AuthAdministrationService.cs
new file mode 100644
index 0000000..aa4c2c1
--- /dev/null
+++ b/Tiku.Infrastructure/Auth/AuthAdministrationService.cs
@@ -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 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);
+ }
+}
diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs
index 6ef8d16..15c0125 100644
--- a/Tiku.Infrastructure/Auth/AuthService.cs
+++ b/Tiku.Infrastructure/Auth/AuthService.cs
@@ -226,6 +226,127 @@ public sealed class AuthService(
request.IpAddress, request.UserAgent, cancellationToken);
}
+ public async Task 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 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 FindChallengeAsync(
string token,
AuthChallengePurpose purpose,
diff --git a/Tiku.Infrastructure/Auth/AuthSessionStore.cs b/Tiku.Infrastructure/Auth/AuthSessionStore.cs
index fde2f65..a414801 100644
--- a/Tiku.Infrastructure/Auth/AuthSessionStore.cs
+++ b/Tiku.Infrastructure/Auth/AuthSessionStore.cs
@@ -196,6 +196,28 @@ public sealed class AuthSessionStore(
return new AuthSessionValidationResult(userId, realm, tenantId);
}
+ public async Task 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> 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";
+ }
}
diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs
index baf8890..21a16b4 100644
--- a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs
+++ b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs
@@ -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")
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index 7c6896e..4ab0bb8 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -109,6 +109,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -125,6 +126,8 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddOptions();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -144,6 +147,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddOptions();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddSingleton();
diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
index b142f5a..75cedd2 100644
--- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
+++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
@@ -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 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> 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 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 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 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 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 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();
+ var storage = scopedProvider.GetRequiredService();
+ 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 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();
+
+ 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(
+ 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(
+ TarWriter archive,
+ string name,
+ IEnumerable 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 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);
diff --git a/Tiku.Infrastructure/Observability/WorkerTelemetry.cs b/Tiku.Infrastructure/Observability/WorkerTelemetry.cs
new file mode 100644
index 0000000..afd908f
--- /dev/null
+++ b/Tiku.Infrastructure/Observability/WorkerTelemetry.cs
@@ -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 JobCounter = Meter.CreateCounter("tiku.worker.jobs");
+ private static readonly Histogram JobDuration = Meter.CreateHistogram("tiku.worker.job.duration", "ms");
+ private static readonly Counter ScanCounter = Meter.CreateCounter("tiku.asset.security_scans");
+ private static readonly Histogram ScanDuration = Meter.CreateHistogram("tiku.asset.security_scan.duration", "ms");
+ private static readonly Counter IterationCounter = Meter.CreateCounter("tiku.worker.iterations");
+ private static readonly Histogram IterationDuration = Meter.CreateHistogram("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);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs
index 7efaada..641d475 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/OperationsConfigurations.cs
@@ -271,13 +271,18 @@ internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration 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().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.OutputAssetId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
@@ -285,6 +290,38 @@ internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.ExportAssetId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
+
+internal sealed class WorkerHeartbeatConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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
{
public void Configure(EntityTypeBuilder builder)
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260801024011_P0SystemStrengthening.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260801024011_P0SystemStrengthening.Designer.cs
new file mode 100644
index 0000000..4ca2c60
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260801024011_P0SystemStrengthening.Designer.cs
@@ -0,0 +1,20044 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tiku.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tiku.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(TikuDbContext))]
+ [Migration("20260801024011_P0SystemStrengthening")]
+ partial class P0SystemStrengthening
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_claims");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_claims_user_id");
+
+ b.ToTable("user_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey")
+ .HasName("pk_user_logins");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_logins_user_id");
+
+ b.ToTable("user_logins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name")
+ .HasName("pk_user_tokens");
+
+ b.ToTable("user_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CategoryType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("category_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid")
+ .HasColumnName("subject_id");
+
+ b.Property("SvipQuestionLimit")
+ .HasColumnType("integer")
+ .HasColumnName("svip_question_limit");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_categories");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_categories_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_categories_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_categories_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "SubjectId")
+ .HasDatabaseName("ix_categories_tenant_id_subject_id");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Major", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("StudyTips")
+ .HasColumnType("text")
+ .HasColumnName("study_tips");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_majors");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_majors_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_majors_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_majors_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_majors_tenant_id_school_id");
+
+ b.ToTable("majors", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyModuleId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_module_id");
+
+ b.Property("LegacyParentId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_parent_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("path");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_module_nodes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_module_nodes_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "ParentId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_parent_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_region_id");
+
+ b.ToTable("module_nodes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary");
+
+ b.Property("QuestionId")
+ .HasColumnType("uuid")
+ .HasColumnName("question_id");
+
+ b.Property("TaxonomyNodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("taxonomy_node_id");
+
+ b.Property("TaxonomyOwnerTenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("taxonomy_owner_tenant_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.HasKey("Id")
+ .HasName("pk_question_taxonomy_assignments");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_question_taxonomy_assignments_tenant_id_id");
+
+ b.HasIndex("TaxonomyOwnerTenantId", "TaxonomyNodeId")
+ .HasDatabaseName("ix_question_taxonomy_assignments_taxonomy_owner_tenant_id_taxo~");
+
+ b.HasIndex("TenantId", "QuestionId", "TaxonomyOwnerTenantId", "TaxonomyNodeId")
+ .IsUnique()
+ .HasDatabaseName("ix_question_taxonomy_assignments_tenant_id_question_id_taxonom~");
+
+ b.ToTable("question_taxonomy_assignments", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Region", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("code");
+
+ b.Property("Config")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("config")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FullName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("full_name");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsHot")
+ .HasColumnType("boolean")
+ .HasColumnName("is_hot");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Pinyin")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("pinyin");
+
+ b.Property("ShortName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("short_name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_regions");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_regions_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_regions_tenant_id_legacy_id");
+
+ b.ToTable("regions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Color")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsPrimarySchoolModule")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary_school_module");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Route")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("route");
+
+ b.Property